import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs'; import {SoftHintPainter,softHintTokens as T,softHighlightAt,targetBreath} from './src/soft-hint.mjs'; import {prepareHintMotion} from './src/hint-motion.mjs';import {boot} from './qa/runtime-harness.mjs'; import {renderBrightBoard,hintGeometry} from './src/bright-classroom/board-renderer.mjs';import {inside} from './src/bright-classroom/geometry.mjs'; const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id)),routes=JSON.parse(fs.readFileSync(new URL('./src/prepared-routes.json',import.meta.url))).routes; function context(events=[]){const stack=[];return new Proxy({globalCompositeOperation:'source-over',globalAlpha:1,measureText:()=>({width:20}),save(){stack.push([this.globalCompositeOperation,this.globalAlpha]);},restore(){[this.globalCompositeOperation,this.globalAlpha]=stack.pop();},createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}}),fill(){events.push(['fill',this.globalCompositeOperation]);},fillRect(){events.push(['rect',this.globalCompositeOperation]);},drawImage(){events.push(['image',this.globalCompositeOperation]);}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});} const hint={route:['a','b','c'],guides:[{a:'a',b:'b',points:[[30,30],[150,30]]},{a:'b',b:'c',points:[[150,30],[150,160]]}],keepouts:[],visitedShapes:[],boardWidth:200,boardHeight:220,dpr:2}; test('A3 width, one wide patch and brighter body preserve original design proportions',()=>{const original=JSON.parse(fs.readFileSync(new URL('../docs/design/bright-classroom/concepts/hint-soft-ribbon-a3.tokens.json',import.meta.url)));assert.equal(T.body.widthRatio,.235);assert.equal(T.highlight.count,1);assert.equal(T.body.crownWidthRatio,.76);assert.equal(T.endpointFade.radiusRatio,.39);assert.equal(T.body.widthRatio,original.body.widthRatio);assert.notDeepEqual(T.body.gradient,original.body.gradient);for(const pitch of [20,36,104,200])assert.equal(prepareHintMotion(hint,pitch,[]).width,Math.max(7,Math.min(26,pitch*.235)));}); test('ribbon caches its body, draws source-atop only offscreen and starts each frame clean',()=>{let canvases=0;const internal=[],out=[],painter=new SoftHintPainter(()=>{canvases++;return {width:0,height:0,getContext:()=>context(internal)};}),ctx=context(out);painter.draw(ctx,hint,50,300,[]);const base=painter.base;for(let i=0;i<60;i++)painter.draw(ctx,hint,50,300+i*16,[]);assert.equal(canvases,2);assert.equal(painter.base,base);assert.ok(internal.some(e=>e[1]==='source-atop'));assert.ok(out.every(e=>e[1]==='source-over'));painter.reset();assert.equal(painter.base,null);}); test('one patch travels at 60px/s on the full route and does not restart when player advances',()=>{const motion=prepareHintMotion(hint,50,[]);const a=softHighlightAt(motion,300),b=softHighlightAt(motion,800);assert.ok(Math.abs(b.at-a.at-30)<.001);assert.equal(a.cycle,Math.max(1800,motion.total/60*1000));assert.ok(a.at{const log=[],p=new SoftHintPainter(()=>null),ctx=context(log);p.draw(ctx,{...hint,reducedMotion:true},50,0,[]);assert.ok(!log.some(e=>e[1]==='source-atop'));assert.equal(ctx.globalCompositeOperation,'source-over');assert.equal(targetBreath(0,true),1);assert.equal(targetBreath(0),.65);assert.equal(targetBreath(1100),1);}); test('real target contours and smoothed routes stay attached to actual 200-level geometry',()=>{const ctx=context();for(const level of levels){const route=routes[level.id],scene=renderBrightBoard(ctx,level,[],375,468,{preview:true,now:0}),h=hintGeometry(scene,[],route),m=prepareHintMotion(h,scene.pitch,[]);assert.equal(h.targetId,route.at(-1));assert.equal(h.targetShape.rings,scene.byId.get(route.at(-1)).rings);assert.ok(m.runs.length);for(const run of m.runs)for(const p of run){const valid=h.routeShapes.some(s=>s.parts.some(r=>inside(p,r)));if(!valid)assert.ok(h.guides.some(g=>g.points.some(q=>Math.hypot(q[0]-p[0],q[1]-p[1]){const ui=boot();ui.select(3);ui.button('指路');ui.advance(400);const before=ui.api.snapshot();for(let i=0;i<10;i++)ui.advance(16);const after=ui.api.snapshot();assert.ok(after.softHint.targetCacheHits>before.softHint.targetCacheHits);assert.equal(after.softHint.builds,before.softHint.builds);const sequence=after.feedback.sequence;const h=after.hint;for(const id of routes[levels[3].id].slice(0,h.end+1))ui.cell(id);assert.equal(ui.api.snapshot().hint,null);ui.handlers.Hide();assert.equal(ui.frames.size,0);assert.ok(ui.api.snapshot().feedback.sequence>=sequence);});