138 lines
10 KiB
JavaScript
138 lines
10 KiB
JavaScript
import {feedback} from './src/feedback.mjs';
|
|
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;i<guide.length;i++)for(let t=0;t<=1;t+=.1){const a=guide[i-1],b=guide[i],p=[a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t];assert.ok(g.parts.some(poly=>p[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.clue<l.cells.length)),route=solve(level)[0],n=level.cells.find(c=>c.clue>1&&c.clue<level.cells.length).clue,ctx=context();
|
|
renderBrightBoard(ctx,level,route.slice(0,n-1),375,400,{now:1000});
|
|
feedback.reset();const originalNow=feedback.now;feedback.now=()=>1100;feedback.emit('checkpoint',level,route[n-1],n);feedback.now=originalNow;
|
|
let scene=renderBrightBoard(ctx,level,route.slice(0,n),375,400,{now:1100});
|
|
assert.equal(scene.bursts.length,1);assert.equal(scene.animating,true);
|
|
scene=renderBrightBoard(ctx,level,route.slice(0,n),375,400,{now:1200});assert.equal(scene.bursts.length,1);
|
|
scene=renderBrightBoard(ctx,level,route.slice(0,n),375,400,{now:2800});assert.equal(scene.bursts.length,0);
|
|
feedback.reset();const wrong=context();renderBrightBoard(wrong,level,[],375,400,{now:1000});
|
|
scene=renderBrightBoard(wrong,level,[route[n-1]],375,400,{now:1100});assert.equal(scene.bursts.length,0);
|
|
});
|
|
|
|
test('checkpoint pulse is small, centered in time and settles without looping',async()=>{
|
|
const {checkpointScale}=await import('./src/bright-classroom/board-renderer.mjs');
|
|
assert.equal(checkpointScale(0),1);assert.equal(checkpointScale(130),1.035);assert.equal(checkpointScale(260),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(130,true),1);
|
|
});
|