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){ 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]{ const {exitTiming,travelProgress}=await import('../src/exit-animation.js'); // Below the knee the old guarantee still holds exactly: same cruising speed // for every piece, no cap. distance*120 stays under 2400ms up to distance 20. for(const distance of [1,5,15]){ 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); assert.equal(timing.capped,false); } // Past the knee the piece speeds up so the journey stays watchable. Uncapped // linear timing put a single departure at seventeen seconds. const long=exitTiming({travelPoints:[{x:0,y:0},{x:150,y:0}],steps:[]}); assert.equal(long.capped,true); assert.ok(long.duration<4200,`一条超长路线不该超过 4.2s,实际 ${long.duration}`); assert.ok(long.duration>exitTiming({travelPoints:[{x:0,y:0},{x:20,y:0}],steps:[]}).duration, '再长的路线仍然要比中等路线久,长度感不能丢'); // Monotone: a longer route never plays shorter than a shorter one. let prev=0; for(const d of [1,5,15,25,40,80,150,400]){ const ms=exitTiming({travelPoints:[{x:0,y:0},{x:d,y:0}],steps:[]}).duration; assert.ok(ms>=prev,`距离 ${d} 的时长 ${ms} 不该短于更短的路线 ${prev}`); prev=ms; } }); 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); } });