Files
HexTrails/tests/themes.test.js
T
cabinw e92cccba82 feat: version campaigns and document original puzzle design
Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
2026-09-09 13:17:30 +08:00

80 lines
5.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js';
import { Game, clone, validateLevel, available } from '../src/core.js';
import { LEVELS } from '../src/levels.js';
import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js';
import { layout } from '../src/geometry.js';
test('new pigments import without changing route solutions; out-of-range values are rejected',()=>{
for(let color=0;color<12;color++){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.deepEqual(validateLevel(level),[]);assert.deepEqual(available(level).map(t=>t.id),available(LEVELS[0]).map(t=>t.id));}
for(const color of [-1,12,1.5]){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.ok(validateLevel(level).length);}
});
test('scene text and all directional arrows remain readable across curated themes',()=>{
for(const p of PIGMENTS)assert.ok(contrast(p.arrow,p.color)>=3,p.name);
for(const t of LEVEL_THEMES){assert.ok(contrast(t.ink,t.background)>=4.5,t.name);assert.ok(t.palette.length>=3&&t.palette.length<=4);for(const i of t.palette)assert.ok(PIGMENTS[i]);}
assert.deepEqual([29,30,49,50,69,70].map(n=>themeForScore(n).id),['lagoon','apricot','apricot','lavender','lavender','rose']);
});
test('theme assignments are stable after removal and undo; all new colors fall back from the four-frame atlas',()=>{
const game=new Game(LEVELS[0]),view=layout(game.level.cells,390,390),theme=LEVEL_THEMES[1];
const capture=()=>{const sprites=[];drawScene({polygon(){},line(){},circle(){},sprite(i,x,y){sprites.push([i,x,y]);return true;}},game.level,game.tiles,view,{theme});return sprites;};
const before=capture();game.tap(available(game.level,game.tiles)[0].id);const after=capture();for(const entry of after)assert.ok(before.some(b=>JSON.stringify(b)===JSON.stringify(entry)));game.undo();assert.deepEqual(capture(),before);
const painter=new CanvasPainter({});painter.atlas={complete:true,naturalWidth:1293};for(let i=4;i<12;i++)assert.equal(painter.sprite(i,0,0,10),false);
});
test('exit fragments stay near the actual board port, finish cleanly, and respect reduced motion',()=>{
const game=new Game(LEVELS[0]),route=game.tap(available(game.level,game.tiles)[0].id),view=layout(game.level.cells,390,390);
const shapes=[],p={polygon(pts,c){shapes.push({pts,c});},line(){},circle(){}};
const edge=exitGeometry(route,view).edge;
drawExit(p,route,view,.95,{...route.tile,color:10});assert.equal(shapes.length,6);
for(const s of shapes){assert.equal(s.c,PIGMENTS[10].color);for(const point of s.pts)assert.ok(Math.hypot(point.x-edge.x,point.y-edge.y)<view.size);}
shapes.length=0;drawExit(p,route,view,1,route.tile);assert.equal(shapes.length,0);
drawExit(p,route,view,.95,route.tile,true);assert.equal(shapes.length,0);
});
test('edge exits are brisk and longer routes preserve a bounded readable travel phase',async()=>{
const {exitTiming}=await import('../src/exit-animation.js');
const edge={status:'exit',steps:[{q:0,r:0,enter:null,exit:0}],end:{q:0,r:-1}};
const short=exitTiming(edge);
const long=exitTiming({...edge,steps:[edge.steps[0],{q:0,r:-1,enter:3,exit:0},{q:0,r:-2,enter:3,exit:0}]});
assert.ok(short.duration>260&&short.duration<400);
assert.ok(long.duration>short.duration&&long.duration<1200);
for(const timing of [short,long])assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
});
test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
const {smoothMotion}=await import('../src/exit-animation.js');
const positions=Array.from({length:101},(_,i)=>smoothMotion(i/100));
const steps=positions.slice(1).map((x,i)=>x-positions[i]);
assert.equal(positions[0],0);assert.equal(positions.at(-1),1);
assert.ok(steps.every(d=>d>=0));
assert.ok(steps[0]<steps[49]/10&&steps.at(-1)<steps[49]/10);
assert.equal(smoothMotion(-1),0);assert.equal(smoothMotion(2),1);
});
test('curved and long routes share the same cruising speed without a duration cap',async()=>{
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
for(const distance of [1,5,15,30]){
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
assert.equal(timing.travelMs,distance*120+Math.min(80,distance*40));
const t=timing.travelMs*.5,dt=1;
const speed=(travelProgress((t+dt)/timing.travelMs,timing.travelMs)-travelProgress(t/timing.travelMs,timing.travelMs))*distance;
assert.ok(Math.abs(speed-1/120)<1e-9);
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
assert.ok(Math.abs(timing.duration-timing.travelMs-180)<1e-9);
}
});
test('showcase boards open with a long curved route and can be cleared completely',async()=>{
const {createStudyLevel}=await import('../src/study-levels.js');
const {available,trace,Game,validateLevel}=await import('../src/core.js');
for(const [radius,minLength] of [[3,5],[5,8],[6,11]]){
const level=createStudyLevel(radius),game=new Game(level);
assert.deepEqual(validateLevel(level),[]);
const routes=available(level,level.tiles).map(t=>trace(level,t,level.tiles));
assert.ok(routes.some(r=>r.steps.length>=minLength&&r.steps.some(s=>s.enter!==null&&(s.enter+3)%6!==s.exit)));
while(game.tiles.length){const tile=available(game.level,game.tiles)[0];assert.ok(tile);game.tap(tile.id);}
assert.ok(game.won);
}
});