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=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{ 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]]); });