Files
MatchMaster_level/scripts/verify-windows-editor.mjs
2026-10-21 13:43:19 +08:00

80 lines
18 KiB
JavaScript

import assert from 'node:assert/strict';
import {readFile,stat,realpath} from 'node:fs/promises';
import path from 'node:path';
import {createHash} from 'node:crypto';
import {gunzipSync} from 'node:zlib';
import {pathToFileURL} from 'node:url';
const root=await realpath(path.resolve(process.argv[2]));
const moduleAt=relative=>import(pathToFileURL(path.join(root,relative)).href);
const checks=[],servers=[];const manifest=JSON.parse(await readFile(path.join(root,'PACKAGE_MANIFEST.json')));
async function get(url){const r=await fetch(url);assert.equal(r.status,200,url);return r.json();}
async function post(url,body){const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});assert.ok(r.ok,`${url}: ${r.status} ${await r.clone().text()}`);return r.json();}
try{
const {startPortableEditor}=await moduleAt('StartEditor.mjs');
const app=await startPortableEditor({port:0,open:false});servers.push(app.server);
const status=await get(app.url+'/api/status');assert.equal(status.version,manifest.editor);assert.equal(status.game.count,manifest.levelCounts['game-data/assets/custom/Json']);assert.equal(status.game.timeBonus[114],30);assert.ok(status.sources.every(s=>s.available));assert.ok(!JSON.stringify(status).includes('/Users/ruoruo'));checks.push(`independent portable startup: ${status.game.count} levels, AB metadata, no owner path`);
for(const file of ['/file-actions-ui.js','/','/app.js','/lasers-ui.js','/laser-controls-ui.js','/src/lasers.js','/mechanisms-ui.js','/overlays-ui.js','/color-controls-ui.js','/src/color-controls.js','/src/overlays.js','/src/gate-labels.js','/local-screenshot-ui.js','/local-vision-worker.js','/src/local-vision.js','/src/block-labels.js','/src/vision/level-digit-templates.js','/src/vision/digit-templates.js','/src/vision/capture-digit-templates.js','/src/vision/sample-digit-templates.js','/screenshot-ui.js','/src/screenshot-import.js','/examples/screenshot-level18.json','/src/model.js','/src/placement.js','/src/mechanisms.js','/colorBlock-level-generator/src/core/shapes.js','/colorBlock-level-generator/src/adapters/wall-slots.js'])assert.equal((await fetch(app.url+file)).status,200,file);checks.push('all editor HTML/JS/shared modules served');
const laserAssets=JSON.parse(await readFile(path.join(root,'colorBlock-level-editor/web/assets/laser/manifest.json'),'utf8'));
assert.equal(Object.keys(laserAssets.frames).length,62);
for(const [name,frame] of Object.entries(laserAssets.frames)){
const response=await fetch(app.url+'/assets/laser/'+name);assert.equal(response.status,200,name);assert.equal(response.headers.get('content-type'),'image/png',name);
const image=Buffer.from(await response.arrayBuffer());assert.deepEqual(image.subarray(0,8),Buffer.from([137,80,78,71,13,10,26,10]),name);
assert.equal(createHash('sha256').update(image).digest('hex'),frame.sha256,name);
}
checks.push('all 62 packaged laser PNGs served with original source hashes');
const level=(await get(app.url+'/api/level?source=matchmaster&name=level1.json')).data;
const saved=await post(app.url+'/api/save',{level});assert.ok(saved.path.startsWith(root+path.sep));assert.deepEqual(JSON.parse(await readFile(saved.path,'utf8')),level);checks.push('draft save stays in relocated package and roundtrips exactly');
const {Editor,emptyLevel}=await moduleAt('colorBlock-level-editor/src/model.js');const {setCover,setFlowers,hasJump}=await moduleAt('colorBlock-level-editor/src/overlays.js');const edited=new Editor();edited.add(0,1,2,2,setFlowers(setCover({}, {mode:'fixed',group:1,count:3}),{send:'1',receive:'11111',color:1}));edited.addGate(0,3,1,7,{jump:true,jumpDirection:false,jumpCell:1});assert.deepEqual(edited.data.WALL_INFO[0].map(hasJump),[false,true,false]);const overlaySave=await post(app.url+'/api/save',{level:edited.data});assert.deepEqual(JSON.parse(await readFile(overlaySave.path,'utf8')),edited.data);checks.push('packaged paper box, flowers and per-cell jump save with exact raw fields');
for(const [width,height] of [[2,5],[6,3],[10,10]]){
const rectangular=new Editor(emptyLevel(10,10)),index=rectangular.addSpawn(0,0,width,height);
rectangular.batch(index,'add');rectangular.editBatch(index,0,'add',0,1,0,0);
const result=await post(app.url+'/api/save',{level:rectangular.data});
assert.deepEqual(result.validation.errors,[]);assert.deepEqual(JSON.parse(await readFile(result.path,'utf8')),rectangular.data);
await post(app.url+'/api/export-folder',{directory:path.dirname(result.path)});
const exported=await post(app.url+'/api/export',{level:rectangular.data});
assert.deepEqual(JSON.parse(await readFile(exported.path,'utf8')),rectangular.data);
assert.deepEqual([rectangular.data.LEVEL_INFO[0].spawnGates[0].width,rectangular.data.LEVEL_INFO[0].spawnGates[0].height],[width,height]);
}
checks.push('packaged 2x5, 6x3 and 10x10 spawn regions with batch members save and export exactly');
const oversized=new Editor(),before=oversized.serialize();
assert.throws(()=>oversized.addSpawn(0,0,10,10),/区域必须在盘内/);assert.equal(oversized.serialize(),before);assert.equal(oversized.past.length,0);
checks.push('10x10 region on an 8x8 map is rejected without data or undo changes');
const laser=new Editor();laser.createLaserGroup(7,'portable_laser');
const laserDraft=await post(app.url+'/api/save',{level:laser.data});assert.deepEqual(JSON.parse(await readFile(laserDraft.path,'utf8')),laser.data);assert.ok(laserDraft.validation.errors.some(e=>e.code==='laser-head-slot'));
await post(app.url+'/api/export-folder',{directory:path.dirname(laserDraft.path)});
const incompleteExport=await fetch(app.url+'/api/export',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({level:laser.data})});
assert.equal(incompleteExport.status,400);assert.match((await incompleteExport.json()).error,/激光/);
laser.setLaserHead(0,'emitter',2);laser.setLaserHead(0,'receiver',26);laser.addLaserGate(0,8,3,8);
const laserSave=await post(app.url+'/api/save',{level:laser.data});assert.deepEqual(laserSave.validation.errors,[]);assert.deepEqual(JSON.parse(await readFile(laserSave.path,'utf8')),laser.data);
const laserExport=await post(app.url+'/api/export',{level:laser.data});assert.ok(laserExport.path.startsWith(root+path.sep));assert.deepEqual(JSON.parse(await readFile(laserExport.path,'utf8')),laser.data);
assert.equal(laser.data.LEVEL_INFO[0].lasers[0].color,7);assert.ok(laser.data.WALL_INFO[0].every(w=>w.color===8&&w.laserId==='portable_laser'));checks.push('laser drafts save incomplete, export rejects missing parts, and complete three-cell gates roundtrip with independent beam/door colors');
const multi=new Editor();const g=multi.addSpawn(2,2,3);for(let b=0;b<5;b++){multi.batch(g,'add');multi.editBatch(g,b,'add',0,1,2,2);multi.editBatch(g,b,'add',0,2,3,2);}const multiSave=await post(app.url+'/api/save',{level:multi.data});const reopened=JSON.parse(await readFile(multiSave.path,'utf8'));assert.deepEqual(reopened,multi.data);assert.equal(reopened.LEVEL_INFO[0].spawnGates[0].spawnQueue.length,5);assert.equal(reopened.LEVEL_INFO[0].spawnGates[0].spawnQueue.flatMap(b=>b.members).length,10);const {colorChoices}=await moduleAt('colorBlock-level-editor/src/color-controls.js');assert.equal(colorChoices('encoded')[0].value,'0');assert.equal(colorChoices('flower')[0].value,'1');assert.equal(colorChoices('flower')[0].hex,'#e7564c');checks.push('five multi-member batches and distinct visual color palettes survive portable save');
const {gateAnnotations}=await moduleAt('colorBlock-level-editor/src/gate-labels.js');const labeled=new Editor();labeled.addGate(0,2,3,3,{freeze:5});const raw=labeled.serialize();assert.deepEqual(gateAnnotations(labeled.data)[0].rows,['冰冻5','色号 3']);assert.equal(labeled.serialize(),raw);checks.push('portable gate labels retain real counters/colors without modifying level data');
const shotSpec=await get(app.url+'/examples/screenshot-level18.json');const {screenshotToLevel}=await moduleAt('colorBlock-level-editor/src/screenshot-import.js');const shot=screenshotToLevel(shotSpec);assert.equal(shot.summary.pieces,18);assert.equal(shot.summary.gates,6);assert.equal(shot.level.WALL_INFO[0][0].freeze,14);checks.push('screenshot sample converts 18 pieces/6 gates with initial freeze counts in relocated package');
const {reconstructLocal}=await moduleAt('colorBlock-level-editor/src/local-vision.js');const localFrames=await Promise.all([0,1].map(async i=>({...JSON.parse(await readFile(new URL('../colorBlock-level-editor/test/fixtures/local-vision/shot'+i+'.json',import.meta.url))),data:new Uint8ClampedArray(gunzipSync(await readFile(new URL('../colorBlock-level-editor/test/fixtures/local-vision/shot'+i+'.rgba.gz',import.meta.url))))})));const localSpec=reconstructLocal(localFrames,{id:'990008',time:185}).spec;assert.equal(localSpec.pieces.length,18);assert.deepEqual(localSpec.gates.map(g=>g.properties.freeze),[14,12,16,13,6,undefined]);const localLevel=screenshotToLevel(localSpec).level;const localSaved=await post(app.url+'/api/save',{level:localLevel});assert.deepEqual(JSON.parse(await readFile(localSaved.path,'utf8')),localLevel);checks.push('packaged offline vision measures original calibration pixels and saves 18 pieces/6 gates; fixture lives outside release, no API');
const frozenFrames=await Promise.all([0,1,2,3,4].map(async i=>({...JSON.parse(await readFile(new URL('../colorBlock-level-editor/test/fixtures/local-vision/level17/shot'+i+'.json',import.meta.url))),data:new Uint8ClampedArray(gunzipSync(await readFile(new URL('../colorBlock-level-editor/test/fixtures/local-vision/level17/shot'+i+'.rgba.gz',import.meta.url))))})));const frozenResult=reconstructLocal([frozenFrames[4],...frozenFrames.slice(0,4)],{id:'990009'});assert.equal(frozenResult.selection.index,1);assert.equal(frozenResult.spec.time,140);assert.equal(frozenResult.spec.pieces.length,24);assert.deepEqual(frozenResult.spec.pieces.filter(p=>p.properties.type===4).map(p=>[p.color,p.properties.freezeTime]),[[7,17],[10,16],[2,16],[1,16],[1,5],[2,5],[7,5]]);const frozenLevel=screenshotToLevel(frozenResult.spec).level;const frozenSaved=await post(app.url+'/api/save',{level:frozenLevel});assert.deepEqual(JSON.parse(await readFile(frozenSaved.path,'utf8')),frozenLevel);checks.push('packaged five-image recognition selects max timer, preserves seven freeze counts and saves 24 pieces/5 gates');
const {blockCaption}=await moduleAt('colorBlock-level-editor/src/block-labels.js');assert.deepEqual(blockCaption({id:250,type:1,stacking:3,color:7}).rows,['叠加3','色号7']);const {readLevelNumber}=await moduleAt('colorBlock-level-editor/src/local-vision.js');assert.deepEqual(blockCaption({id:210,type:4,color:3,freezeTime:10}).rows,['冰冻10','色号3']);assert.deepEqual(frozenFrames.map(f=>readLevelNumber(f).value),[17,17,17,17,17]);assert.deepEqual(localFrames.map(f=>readLevelNumber(f).value),[18,18]);assert.equal(reconstructLocal(frozenFrames).spec.id,'17');checks.push('packaged full block captions hide IDs and Level HUD supplies 17/18 without confusing countdowns');
const edgeCases=await Promise.all([7,8].map(async number=>{const frames=await Promise.all([0,1].map(async i=>({...JSON.parse(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level${number}/shot${i}.json`,import.meta.url))),data:new Uint8ClampedArray(gunzipSync(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level${number}/shot${i}.rgba.gz`,import.meta.url))))})));return reconstructLocal(frames).spec;}));const {screenshotReadiness}=await moduleAt('colorBlock-level-editor/src/screenshot-import.js');assert.deepEqual(edgeCases.map(s=>[s.width,s.height,s.pieces.length,s.gates.length]),[[6,8,27,4],[6,8,20,5]]);assert.equal(screenshotReadiness(edgeCases[0]).result,null);assert.ok(screenshotReadiness(edgeCases[0]).errors.some(s=>s.includes('门 2计数')));assert.ok(screenshotReadiness(edgeCases[1]).result);checks.push('L/H board extents, yellow bar separation, whole dominoes, merged ice gates and actionable missing-digit errors in portable runtime');
const clearFrames=await Promise.all([0,1].map(async i=>({...JSON.parse(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level7-clear/shot${i}.json`,import.meta.url))),data:new Uint8ClampedArray(gunzipSync(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level7-clear/shot${i}.rgba.gz`,import.meta.url))))})));const clearSpec=reconstructLocal(clearFrames).spec;assert.deepEqual([clearSpec.width,clearSpec.height,clearSpec.pieces.length,clearSpec.gates.length],[6,8,27,4]);assert.deepEqual(clearSpec.gates.map(g=>g.properties.freeze),[undefined,24,10,17]);assert.equal(screenshotReadiness(clearSpec).errors.length,0);const {correctLocalCells}=await moduleAt('colorBlock-level-editor/src/local-vision.js');const merged=structuredClone(clearSpec),yellow=merged.pieces.filter(p=>p.color===8);merged.pieces=merged.pieces.filter(p=>p.color!==8);merged.pieces.push({...yellow[0],cells:yellow.flatMap(p=>p.cells)});const regrouped=correctLocalCells(merged,[[1,0],[1,1],[1,2]],'merge');assert.equal(regrouped.pieces.length,27);assert.equal(screenshotReadiness(regrouped).errors.length,0);checks.push('clear desktop capture: aligned 6x8/27/4, complete 24/10/17 counts, supplemental colors and extract-three-cells conversion');
const level19Frames=await Promise.all([0,1,2].map(async i=>({...JSON.parse(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level19/shot${i}.json`,import.meta.url))),data:new Uint8ClampedArray(gunzipSync(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level19/shot${i}.rgba.gz`,import.meta.url))))})));
const level19=reconstructLocal(level19Frames).spec;assert.deepEqual([level19.id,level19.time,level19.width,level19.height,level19.pieces.length,level19.gates.length],['19',135,6,9,27,8]);assert.deepEqual(level19.pieces.filter(p=>p.properties.type===4).map(p=>[p.color,p.properties.freezeTime]),[[7,10],[2,10]]);assert.deepEqual(level19.gates.filter(g=>g.properties.special===3).map(g=>g.properties.freeze),[20,24,12,12,8,20]);assert.equal(screenshotReadiness(level19).errors.length,0);assert.equal(screenshotReadiness(reconstructLocal(level19Frames.slice(0,2)).spec).errors.length,2);
assert.match(await readFile(path.join(root,'colorBlock-level-editor/web/local-screenshot-ui.js'),'utf8'),/colorSpaceConversion:'none'/);assert.match(await readFile(path.join(root,'colorBlock-level-editor/docs/SCREENSHOT_RECOGNITION_EXPLAINED.html'),'utf8'),/grid-new/);checks.push('level19: red HUD, raw-ICC decoder, 6x9/27/8, two frozen 10s, separate top gates, hidden-only missing colors, complete offline guide');
const level21Frames=await Promise.all([0,1,2,3].map(async i=>({...JSON.parse(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level21/shot${i}.json`,import.meta.url))),data:new Uint8ClampedArray(gunzipSync(await readFile(new URL(`../colorBlock-level-editor/test/fixtures/local-vision/level21/shot${i}.rgba.gz`,import.meta.url))))})));
const level21=reconstructLocal(level21Frames).spec;assert.deepEqual([level21.width,level21.height,level21.holes.length,level21.pieces.length,level21.gates.length],[8,9,30,21,6]);assert.deepEqual([1,2,3].map(n=>level21.pieces.filter(p=>p.properties.floor===n).length),[2,3,2]);assert.equal(screenshotReadiness(level21).errors.length,0);assert.match(await readFile(path.join(root,'colorBlock-level-editor/docs/BOUNDARIES_0150.html'),'utf8'),/wallSupportedFit/);checks.push('level21 full 8x9 stepped map, same-color seam, three covers and six gates in portable runtime');
const run=await post(app.url+'/api/run',{level,mode:'solve',seconds:2});let job;
for(let n=0;n<100;n++){job=await get(app.url+'/api/jobs/'+run.id);if(job.status!=='running')break;await new Promise(r=>setTimeout(r,100));}
assert.equal(job.status,'done',JSON.stringify(job));assert.equal(job.result.verdict,'solvable');checks.push('analysis worker solves real level1 from relocated package');
const comparison=await post(app.url+'/api/compare',{level});assert.ok(comparison.results.length>0);checks.push('editor comparison uses bundled 1920-level library');
const {createDashboardServer}=await moduleAt('colorBlock-level-generator/src/web/server.js');const gen=await createDashboardServer();servers.push(gen.server);await new Promise(r=>gen.server.listen(0,'127.0.0.1',r));const genUrl='http://127.0.0.1:'+gen.server.address().port;const gs=await get(genUrl+'/api/status');assert.equal(gs.appVersion,manifest.generator);assert.equal(gen.state.corpusIndex.levels.length,1835);assert.equal(gen.state.installTarget.project,null);assert.equal((await fetch(genUrl)).status,200);checks.push({generatorIndexed:gen.state.corpusIndex.levels.length,generatorRawFiles:1836,errors:gen.state.corpusIndex.errors,installTarget:null});
const {startServer}=await moduleAt('colorBlock-similarity-checker/src/server.js');const checker=await startServer({port:0,open:false});servers.push(checker.server);await checker.ready;assert.equal(checker.library.status().count,1920);assert.equal((await fetch(checker.url)).status,200);checks.push('independent checker page indexes 1920 bundled levels');
const bat=await readFile(path.join(root,'StartEditor.bat'));assert.ok(bat.includes(Buffer.from('runtime\\node.exe')));assert.ok(bat.includes(Buffer.from('pushd "%~dp0"')));assert.ok(bat.includes(Buffer.from('\r\n')));assert.ok((await stat(path.join(root,'runtime/node.exe'))).size>50_000_000);checks.push('Windows launcher uses bundled x64 runtime, quoted relative paths and CRLF');
const runtimeExecutable=await realpath(process.execPath),bundledExecutable=await realpath(path.join(root,'runtime/node.exe'));
const windowsExecutionTested=process.platform==='win32'&&runtimeExecutable.toLowerCase()===bundledExecutable.toLowerCase();
if(windowsExecutionTested)assert.equal(process.version,'v'+manifest.runtime.version);
console.log(JSON.stringify({passed:checks.length,checks,hostPlatform:process.platform,runtimeExecutable,windowsExecutionTested,windowsBatchDoubleClickTested:false},null,2));
}finally{for(const server of servers.reverse()){server.closeAllConnections?.();await new Promise(r=>server.close(r));}}