import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import crypto from 'node:crypto'; import {outlines,buildGeometry,interiorGuide,edgeFor,distance,inside} from './src/bright-classroom/geometry.mjs'; import {renderBrightBoard,hintGeometry} from './src/bright-classroom/board-renderer.mjs'; import {solve,units} from './src/upstream/rules.mjs'; import {applyBrightClassroom} from './bright-classroom-build.mjs'; import {C,label} from './src/bright-classroom/theme.mjs'; import {cushion} from './src/bright-classroom/cushion.mjs'; const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))); const area=p=>p.reduce((n,a,i)=>{const b=p[(i+1)%p.length];return n+a[0]*b[1]-b[0]*a[1];},0)/2; function context(){const ctx=new Proxy({measureText:s=>({width:String(s).length*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16)*.6})},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});return ctx;} test('merged irregular outlines retain holes and never draw internal unit edges',()=>{ const donut=[];for(let y=0;y<3;y++)for(let x=0;x<3;x++)if(x!==1||y!==1)donut.push([x,y]); const rings=outlines(donut);assert.equal(rings.length,2);assert.equal(rings.reduce((n,p)=>n+area(p),0),8);assert.ok(rings.some(p=>area(p)<0)); for(const l of pack.levels.filter(l=>l.geometry!=='hex'))for(const c of l.cells){const u=units(c),r=outlines(u);assert.equal(r.reduce((n,p)=>n+area(p),0),u.length,l.id+':'+c.id);} }); test('all 200 boards fit four screen budgets with stable safe anchors and real shared entrances',()=>{ for(const [w,h]of [[320,220],[375,313],[390,442],[414,494]])for(const level of pack.levels){ const scene=buildGeometry(level,w,h);assert.ok(scene.pitch<=104*Math.min(1,w/375)+.001); for(const g of scene.cells){assert.ok(g.parts.some(p=>inside(g.center,p)),level.id+':'+g.cell.id);assert.ok(g.parts.some(p=>inside(g.labelAnchor,p)),'label must stay on its block '+level.id+':'+g.cell.id);for(const [x,y]of g.parts.flat()){assert.ok(x>=scene.margin-1&&x<=w-scene.margin+1,level.id);assert.ok(y>=0&&y<=h,level.id);}} for(const e of scene.edges.values()){const [a,b]=e.segments[0];assert.ok(distance(a,b)>0);assert.ok(Math.abs(distance(a,e.entry)+distance(e.entry,b)-distance(a,b))<.001);} } }); test('guides through every irregular block stay inside its occupied shape',()=>{ for(const level of pack.levels.filter(l=>l.cells.some(c=>c.shape))){ const scene=buildGeometry(level,375,400); for(const g of scene.cells.filter(g=>g.cell.shape))for(const edge of scene.edges.values()){ if(edge.a!==g.cell.id&&edge.b!==g.cell.id)continue; const guide=interiorGuide(g,g.center,edge.entry,scene);assert.ok(guide.length>=2); for(let i=1;ip[0]>=poly[0][0]-.001&&p[0]<=poly[2][0]+.001&&p[1]>=poly[0][1]-.001&&p[1]<=poly[2][1]+.001),level.id+':'+g.cell.id);} } } }); test('visited, conflicting and hinted category cells retain their material identity',()=>{ const level=pack.levels.find(l=>l.features.includes('alternation')&&l.features.includes('parity')),route=solve(level)[0],ctx=context(); for(const path of [[],route.slice(0,4),route.slice(0,4).reverse(),route]){ const scene=renderBrightBoard(ctx,level,path,375,400),before=JSON.stringify(scene.views); const hint=hintGeometry(scene,path,route.slice(0,4)); assert.equal(JSON.stringify(scene.views),before);assert.equal(hint.visitedShapes.length,path.length); for(const view of scene.views){const c=level.cells.find(c=>c.id===view.cell);assert.equal(view.fill,c.category==='circle'?C.categoryBlueFace:C.categoryOrangeFace);} } }); test('five font subsets match their manifest and fit the package budget',()=>{ const manifest=JSON.parse(fs.readFileSync(new URL('./fonts/manifest.json',import.meta.url))); assert.equal(manifest.outputs.length,5); for(const item of manifest.outputs){const bytes=fs.readFileSync(new URL('./fonts/'+item.file,import.meta.url));assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'),item.sha256);assert.equal(bytes.length,item.bytes);} assert.ok(manifest.outputs.reduce((n,o)=>n+o.bytes,0)<500000); }); test('upstream adapter fails closed if the inspected render seams change',()=>{ assert.throws(()=>applyBrightClassroom('function draw(){}'),/seam changed/); assert.throws(()=>applyBrightClassroom('function draw(){foo()} function roundedRect(){}'),/writeback seam changed/); }); test('procedural cushion pixels preserve holes and light the upper bevel without a border',()=>{ const before=globalThis.wx,images=[]; globalThis.wx={createCanvas:()=>({width:0,height:0,getContext:()=>new Proxy({createImageData:(w,h)=>({width:w,height:h,data:new Uint8ClampedArray(w*h*4)}),putImageData:image=>images.push(image)},{get:(o,k)=>o[k]??(()=>{})})})}; try{ const outer=[[0,0],[61,0],[61,61],[0,61]],hole=[[20,20],[20,41],[41,41],[41,20]],original=JSON.stringify([outer,hole]); assert.ok(cushion(context(),[outer,hole],5,{face:'#8178F4',depthColor:'#5750BA',depth:5,bevel:6,shadow:3,resolution:1})); const image=images.at(-1),pixel=(x,y)=>Array.from(image.data.slice((y*image.width+x)*4,(y*image.width+x)*4+4)); assert.equal(pixel(31,31)[3],0,'hole must remain transparent'); assert.equal(pixel(10,10)[3],255,'occupied face must remain opaque'); assert.ok(pixel(10,2)[0]>pixel(10,59)[0],'upper bevel must be lighter than lower bevel'); assert.equal(JSON.stringify([outer,hole]),original,'material must not mutate logical rings'); }finally{if(before===undefined)delete globalThis.wx;else globalThis.wx=before;} }); test('visible glyph bounds are centered independently of font ascent and side bearings',()=>{ let draw; const ctx={measureText:()=>({width:28,actualBoundingBoxLeft:2,actualBoundingBoxRight:20,actualBoundingBoxAscent:30,actualBoundingBoxDescent:2}),fillText:(value,x,y)=>draw={value,x,y}}; label(ctx,'1',100,200,48,C.onPrimary,800,true); assert.equal(draw.x+(20-2)/2,100); assert.equal(draw.y+(2-30)/2,200); const level=pack.levels.find(l=>l.id===pack.playOrder[3]),scene=buildGeometry(level,375,450),big=scene.cells.find(g=>g.cell.w===2&&g.cell.h===2); assert.notDeepEqual(big.labelAnchor,big.center,'reference large-block label is separate from logical input center'); const first=big.parts[0];assert.ok(inside(big.labelAnchor,first)); }); test('stage with zero depth casts alpha shadows without painting a solid backing',()=>{ const before=globalThis.wx,events=[]; globalThis.wx={createCanvas:()=>({width:0,height:0,getContext:()=>new Proxy({createImageData:(w,h)=>({width:w,height:h,data:new Uint8ClampedArray(w*h*4)}),fill:()=>events.push('solid'),drawImage:()=>events.push('image')},{get:(o,k)=>o[k]??(()=>{})})})}; try{ assert.ok(cushion(context(),[[[0,0],[62,0],[62,80],[0,80]]],8,{face:C.stage,depthColor:'#E9D5A9',depth:0,bevel:6,shadow:12,shadowY:4,resolution:1})); assert.equal(events.filter(e=>e==='solid').length,0,'no duplicated yellow slab below the stage'); assert.ok(events.filter(e=>e==='image').length>=2,'face alpha is reused for a soft cast shadow'); }finally{if(before===undefined)delete globalThis.wx;else globalThis.wx=before;} }); test('native width-only TextMetrics still centers the rasterized glyph ink',()=>{ const before=globalThis.wx;let draw; globalThis.wx={createCanvas(){ const canvas={width:0,height:0};let originX=0,baseline=0; canvas.getContext=()=>({setTransform(){},fillText(text,x,y){originX=x;baseline=y;},getImageData(){ const data=new Uint8ClampedArray(canvas.width*canvas.height*4); for(let y=(baseline-18)*2;y<(baseline-2)*2;y++)for(let x=(originX+2)*2;x<(originX+10)*2;x++)data[(y*canvas.width+x)*4+3]=255; return {data}; }});return canvas; }}; try{ const ctx={measureText:()=>({width:25}),fillText:(text,x,y)=>draw={x,y}}; label(ctx,'8',100,200,29,C.ink,800,true); assert.equal(draw.x+6,100);assert.equal(draw.y-10,200); }finally{if(before===undefined)delete globalThis.wx;else globalThis.wx=before;} }); test('fixed channels remain anchored at their block centers after number enlargement',()=>{ for(const level of pack.levels.filter(l=>l.cells.some(c=>c.direction))){ const path=solve(level)[0],scene=renderBrightBoard(context(),level,path,320,300); for(const channel of scene.channels)assert.deepEqual(channel.center,scene.byId.get(channel.cell).center,level.id+':'+channel.cell); } }); test('checkpoint ribbons trigger on correct arrival only and never duplicate per frame',()=>{ const level=pack.levels.find(l=>l.cells.some(c=>c.clue>1&&c.cluec.clue>1&&c.clue{ const {checkpointScale}=await import('./src/bright-classroom/board-renderer.mjs'); assert.equal(checkpointScale(0),1);assert.equal(checkpointScale(180),1.035);assert.equal(checkpointScale(360),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(180,true),1); });