import assert from 'node:assert/strict'; import {readFile,stat,realpath} from 'node:fs/promises'; import path from 'node:path'; 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,2020);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: 2020 levels, AB metadata, no owner path'); for(const file of ['/file-actions-ui.js','/','/app.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'])assert.equal((await fetch(app.url+file)).status,200,file);checks.push('all editor HTML/JS/shared modules served'); 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}=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'); 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'); console.log(JSON.stringify({passed:checks.length,checks,hostPlatform:process.platform,windowsExecutionTested:false},null,2)); }finally{for(const server of servers.reverse()){server.closeAllConnections?.();await new Promise(r=>server.close(r));}}