Files
MatchMaster_level/scripts/verify-macos-editor.mjs

51 lines
16 KiB
JavaScript

import {spawn} from 'node:child_process';
import os from 'node:os';
import assert from 'node:assert/strict';
import {readFile,stat,realpath,mkdtemp,copyFile} 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')));assert.equal(process.platform,'darwin');assert.ok(process.execPath.startsWith(root+path.sep));assert.equal(process.version,'v22.23.3');
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');
for(const file of ['StartEditor.command','启动关卡编辑器.command','runtime/darwin-arm64/node','runtime/darwin-x64/node'])assert.ok((await stat(path.join(root,file))).mode&0o111,file);checks.push('both launchers and both official runtimes retain executable permissions after ZIP extraction');
const proc=spawn(path.join(root,'StartEditor.command'),['--no-open'],{cwd:'/private/tmp',env:{...process.env,PATH:'/usr/bin:/bin'},detached:true,stdio:['ignore','pipe','pipe']});let output='';
try{const launchUrl=await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('launcher timed out: '+output)),20000);proc.on('error',e=>{clearTimeout(timer);reject(e);});proc.on('exit',code=>{clearTimeout(timer);reject(Error('launcher exited '+code+': '+output));});proc.stderr.on('data',s=>output+=s);proc.stdout.on('data',s=>{output+=s;const m=output.match(/http:\/\/127\.0\.0\.1:\d+/);if(m){clearTimeout(timer);resolve(m[0]);}});});const ls=await get(launchUrl+'/api/status');assert.equal(ls.version,manifest.editor);assert.ok(ls.draftsDir.startsWith(root));checks.push('actual .command starts from unrelated cwd with no Homebrew/system Node in PATH; local HTTP works');}finally{try{process.kill(-proc.pid,'SIGTERM');}catch{}}
const failureDir=await mkdtemp(path.join(os.tmpdir(),'mac-editor-missing-runtime-'));await copyFile(path.join(root,'StartEditor.command'),path.join(failureDir,'StartEditor.command'));
const failure=spawn('/bin/zsh',[path.join(failureDir,'StartEditor.command')],{stdio:['ignore','pipe','pipe']});let errorText='';failure.stdout.on('data',b=>errorText+=b);const code=await new Promise(r=>failure.on('close',r));assert.equal(code,1);assert.ok(errorText.includes('运行环境'));checks.push('incomplete extraction fails clearly without attempting a system Node fallback');
console.log(JSON.stringify({passed:checks.length,checks,hostPlatform:process.platform,hostArchitecture:process.arch,node:process.version,runtime:process.execPath,intelHardwareTested:false,recipientQuarantineTested:false},null,2));
}finally{for(const server of servers.reverse()){server.closeAllConnections?.();await new Promise(r=>server.close(r));}}