74 lines
5.3 KiB
JavaScript
74 lines
5.3 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import {hintSegment,createHintController,drawHint} from './src/hints.mjs';
|
|
import {solve} from './src/upstream/rules.mjs';
|
|
const solution=['a','b','c','d','e','f','g'];
|
|
const level={cells:solution.map(id=>({id,...(id==='a'?{clue:1}:id==='c'?{direction:'lr'}:id==='e'?{clue:5}:{} )}))};
|
|
test('empty, partial and wrong routes reveal the first incomplete full segment',()=>{
|
|
for(const path of [[],['a'],['a','b'],['f','e'],['a','d','c']])assert.deepEqual(hintSegment(level,solution,path).route,['a','b','c']);
|
|
assert.deepEqual(hintSegment(level,solution,['a','b','c','f']).route,['c','d','e']);
|
|
assert.equal(hintSegment(level,solution,solution),null);
|
|
});
|
|
test('completion hides the hint; repeat clicks cannot skip; undo and restart return to earlier segments',()=>{
|
|
const h=createHintController();h.request(level,solution,[]);
|
|
assert.ok(h.view(level,solution,['a','d']));
|
|
assert.deepEqual(h.request(level,solution,['a']).route,['a','b','c']);
|
|
assert.equal(h.view(level,solution,['a','b','c']),null);
|
|
assert.equal(h.view(level,solution,['a','b','c','d']),null);
|
|
assert.deepEqual(h.request(level,solution,['a','b','c']).route,['c','d','e']);
|
|
assert.deepEqual(h.view(level,solution,[]).route,['a','b','c']);
|
|
assert.equal(h.view({...level},solution,[]),null);
|
|
});
|
|
test('all 200 levels reveal consecutive segments without changing the player path',()=>{
|
|
const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url)));
|
|
for(const l of pack.levels){
|
|
const s=solve(l)[0],h=createHintController();let path=[];
|
|
while(path.length<s.length){
|
|
const before=[...path],segment=h.request(l,s,path);assert.deepEqual(path,before);assert.ok(segment.route.length>=2,l.id);
|
|
for(let n=path.length;n<=segment.end;n++){path=s.slice(0,n+1);assert.equal(Boolean(h.view(l,s,path)),n<segment.end,l.id);}
|
|
}
|
|
}
|
|
});
|
|
test('hint draws skip player edges and mask visited cells on their own layer; compositing is restored',()=>{
|
|
const events=[],arcs=[],strokes=[];let from,to,stack=[];
|
|
const ctx=new Proxy({globalCompositeOperation:'source-over',save(){stack.push(this.globalCompositeOperation);},restore(){this.globalCompositeOperation=stack.pop();},beginPath(){from=to=null;},moveTo:(...p)=>{from=p;},lineTo:(...p)=>{to=p;},stroke(){if(from&&to)strokes.push([from,to]);},arc:(...p)=>arcs.push(p),fill(){events.push({kind:'fill',mode:this.globalCompositeOperation});}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
|
const poly=[[0,0],[100,0],[100,100],[0,100]];
|
|
drawHint(ctx,{route:['a','b','c'],visitedPolygons:[poly]},{a:[0,0],b:[100,0],c:[200,0]},50,300,['a','b']);
|
|
assert.deepEqual(strokes,[[[100,0],[200,0]],[[100,0],[200,0]]],'only unwalked edge receives hint strokes');
|
|
assert.ok(events.some(e=>e.kind==='fill'&&e.mode==='destination-out'));
|
|
assert.equal(ctx.globalCompositeOperation,'source-over');
|
|
assert.equal(arcs.length,1,'no moving light or rings over already walked edge');
|
|
});
|
|
test('parity is a segment anchor; colour is not; flow moves continuously across cells',()=>{
|
|
const l={cells:solution.map(id=>({id,...(id==='d'?{parity:'odd'}:{category:'circle'})}))};
|
|
assert.deepEqual(hintSegment(l,solution,[]).route,['a','b','c','d']);
|
|
const arcs=[],ctx=new Proxy({arc:(...p)=>arcs.push(p)},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
|
drawHint(ctx,{route:['a','b','c']},{a:[0,0],b:[100,0],c:[100,100]},50,300);
|
|
assert.deepEqual(arcs.at(-3).slice(0,2),[50,0]);
|
|
});
|
|
|
|
test('coral teaching ribbon preserves visited masks and offers direction in reduced motion',()=>{
|
|
for(const reducedMotion of [false,true]){
|
|
const strokes=[],fills=[],stack=[];
|
|
const ctx=new Proxy({globalCompositeOperation:'source-over',save(){stack.push(this.globalCompositeOperation);},restore(){this.globalCompositeOperation=stack.pop();},stroke(){strokes.push({color:this.strokeStyle,mode:this.globalCompositeOperation});},fill(){fills.push(this.globalCompositeOperation);}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
|
|
const path=['a'];
|
|
drawHint(ctx,{route:['a','b'],guides:[{a:'a',b:'b',points:[[0,0],[100,0]]}],visitedShapes:[{rings:[[[0,0],[20,0],[20,20],[0,20]]],radius:3,depth:3,maskPad:4}],keepouts:[{x:40,y:0,w:12,h:15}],reducedMotion},{},50,300,path);
|
|
assert.ok(strokes.some(s=>s.color==='#F49B75'));
|
|
assert.ok(strokes.filter(s=>s.color==='#C96E48'&&s.mode==='source-over').length>=1,'directional chevron');
|
|
assert.ok(fills.includes('destination-out'));
|
|
assert.equal(ctx.globalCompositeOperation,'source-over');
|
|
assert.deepEqual(path,['a']);
|
|
}
|
|
});
|
|
|
|
test('hint rounds shared guide junctions and keeps separate runs disconnected',async()=>{
|
|
const {smoothHintRuns}=await import('./src/hints.mjs');
|
|
const guides=[{a:'a',b:'b',points:[[0,0],[50,0]]},{a:'b',b:'c',points:[[50,0],[50,50]]},{a:'e',b:'f',points:[[100,0],[150,0]]}];
|
|
const runs=smoothHintRuns(guides,10);
|
|
assert.equal(runs.length,2);assert.deepEqual(runs[0][0],[0,0]);assert.deepEqual(runs[0].at(-1),[50,50]);
|
|
assert.ok(!runs[0].some(p=>p[0]===50&&p[1]===0),'sharp junction removed');
|
|
assert.ok(runs[0].some(p=>p[0]>40&&p[0]<50&&p[1]>0&&p[1]<10),'rounded transition');
|
|
assert.deepEqual(runs[1],[[100,0],[150,0]]);
|
|
});
|