Exit timing was linear in path length with no ceiling, so the longest route in the shipped set animated for 17.4s and 19% of all departures ran past two seconds. MOTION already carried exitMaxTravelMs and exitLongMaxMs for this; nothing read them. Below the knee nothing changes — every piece still cruises at the same speed, which is the property the old test protected and the reason motion reads as physical. Past it the piece accelerates rather than taking longer, approaching a ceiling. Length is still legible: longer routes still play longer, just with a compressing tail. The test now states the rule it is protecting instead of "no duration cap": constant speed below the knee, a bounded and monotone tail above it. labs/exit-timing plays the same real route side by side, uncapped against a candidate setting, with knee and ceiling on sliders — the numbers say 3.5s but only watching it says whether that is right. Defaults are provisional at 2.4s / 3.2s pending that call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
6.4 KiB
JavaScript
97 lines
6.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('routes below the knee cruise at one speed; longer ones accelerate instead of dragging',async()=>{
|
|
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);
|
|
}
|
|
});
|