Files
NumberSwipe_New/wechat-campaign-200-r2/bright-classroom.test.mjs
T

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(45),1.3);assert.equal(checkpointScale(90),1);assert.equal(checkpointScale(1000),1);assert.equal(checkpointScale(45,true),1);
});