Files
NumberSwipe_New/wechat-campaign-200-r2/smoke.mjs
T

129 lines
12 KiB
JavaScript

import fs from 'node:fs';
import vm from 'node:vm';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { solve, violations, assertStructure } from './src/upstream/rules.mjs';
const root = path.dirname(fileURLToPath(import.meta.url)), pack = JSON.parse(fs.readFileSync(path.join(root, 'src/upstream/levels.json'))), manifest = JSON.parse(fs.readFileSync(path.join(root, 'source-manifest.json')));
for (const file of manifest.files) {
const hash = p => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
assert.equal(hash(path.join(root, 'src/upstream', file.name)), file.sha256);
assert.equal(hash(path.join(manifest.source, file.name)), file.sha256);
}
const ordered = pack.playOrder.map(id => pack.levels.find(l => l.id === id));
assert.equal(ordered.length, 200);
assert.equal(new Set(pack.playOrder).size, 200);
function boot(storage = new Map(), width = 375, height = 812) {
const handlers = {}, frames = new Map(), played=[];
let frameId = 0,now=100000;
class Clock extends Date {static now(){return now;}}
const text = [],images=[];
const ctx = new Proxy({ drawImage:(image,x,y,w,h)=>images.push({x,y,w,h}),measureText: s => ({ width: [...String(s)].reduce((n,c)=>n+(/[^\x00-\x7f]/.test(c)?1:.55),0)*(Number(/([\d.]+)px/.exec(ctx.font)?.[1])||16) }), fillText: (s, x, y) => text.push({ s, x, y }) }, { get: (o, k) => o[k] ?? (() => { }), set: (o, k, v) => (o[k] = v, true) });
const wx = {setInnerAudioOption:()=>{},createInnerAudioContext:()=>({stop(){},onError(){},play(){assert.ok(fs.existsSync(path.join(root,this.src)),this.src);played.push(this.src);}}), createCanvas: () => ({ getContext: () => ctx }), getWindowInfo: () => ({ windowWidth: width, windowHeight: height, pixelRatio: 2, safeArea: { top: 44, bottom: height - 34 } }), getMenuButtonBoundingClientRect: () => ({ bottom: 80 }), getStorageSync: k => storage.get(k) || '', setStorageSync: (k, v) => storage.set(k, v), removeStorageSync: k => storage.delete(k), getPerformance: () => ({ now: () => Date.now() }), showModal: () => { }, env: { USER_DATA_PATH: '/user' } };
wx.loadFont=p=>{assert.ok(fs.existsSync(path.join(root,p)),'字体必须随包提供 '+p);return path.basename(p,'.ttf');};
for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Hide', 'Show', 'WindowResize'])
wx['on' + name] = cb => handlers[name] = cb;
const global = {};
vm.runInNewContext(fs.readFileSync(path.join(root, 'game.js'), 'utf8'), {Date:Clock, wx, GameGlobal: global, requestAnimationFrame: cb => { frames.set(++frameId, cb); return frameId; }, cancelAnimationFrame: id => frames.delete(id), setTimeout: () => 1, clearTimeout: () => { } }, { timeout: 10000 });
const tick = () => { const entry=frames.entries().next().value;if(!entry)return;const [id,fn]=entry; frames.delete(id);text.length=0;images.length=0; fn(); };
tick();
const touch = (kind, x, y) => handlers[kind]({ changedTouches: [{ identifier: 1, clientX: x, clientY: y }], touches: [{ identifier: 1, clientX: x, clientY: y }] });
const tap = (x, y) => { touch('TouchStart', x, y); touch('TouchEnd', x, y); tick(); };
const api = global.numberSwipeR2;
const button = label => { const b = api.buttons().find(b => b.label === label); assert.ok(b, '缺少按钮 ' + label); assert.ok(b.enabled, '按钮不可用 ' + label); tap(b.x + b.w / 2, b.y + b.h / 2); };
const cell = id => { const s=api.snapshot(),[x,y]=s.centers[id];tap(x+s.boardRect.x,y+s.boardRect.y); };
const select = i => { if(api.buttons().some(b=>b.label==='返回游戏'))button('返回游戏'); if (api.snapshot().screen !== 'select')
button('选关'); const label = String(i + 1); let steps = 0; while (!api.buttons().some(b => b.label === label || b.label === label + ' ✓')) {
button(api.snapshot().selection.chapter>Math.floor(i/10)+1?'上一页':'下一页');
assert.ok(++steps < 25);
} button(api.buttons().some(b => b.label === label) ? label : label + ' ✓'); if (api.buttons().some(b => b.label === '返回游戏'))
button('返回游戏'); assert.equal(api.snapshot().level, ordered[i].id); };
return { advance(ms){now+=ms;tick();}, api, button, cell, select, tick, handlers, frames, storage, text, played,images };
}
const wallLesson=boot();wallLesson.select(4);wallLesson.button('规则');wallLesson.advance(1200);assert.equal(wallLesson.api.snapshot().lesson.finished,false);assert.ok(wallLesson.frames.size>0,'教学超过1.1秒仍需继续刷新');wallLesson.advance(wallLesson.api.snapshot().lesson.duration-1200+50);assert.equal(wallLesson.api.snapshot().lesson.finished,true,'墙壁教学必须播到完成');wallLesson.advance(900);assert.equal(wallLesson.api.snapshot().lesson.finished,true);wallLesson.advance(100);assert.equal(wallLesson.api.snapshot().lesson.finished,false,'停留1秒后自动重播');wallLesson.button('返回游戏');wallLesson.advance(6000);assert.equal(wallLesson.api.snapshot().lesson,null,'离开后停止教学');
const ui = boot(), rows = [];
for (let i = 0; i < 200; i++) {
const l = ordered[i];
assertStructure(l);
const paths = solve(l, { limit: 2, nodeLimit: 1500000 });
assert.equal(paths.length, 1, '非唯一 ' + l.id);
const solution = paths[0];
assert.equal(violations(l, solution).length, 0);
ui.select(i);
ui.button('规则');
assert.ok(!/共边|落格|访问步号/.test(ui.api.snapshot().rules),l.id);
if(l.tutorial){const before=ui.api.snapshot();for(let j=0;j<25;j++)ui.advance(1300);const after=ui.api.snapshot();assert.deepEqual(after.path,before.path,'演示不能修改玩家路线');assert.deepEqual(after.centers,before.centers,'演示不能改变游戏布局');assert.deepEqual(after.violations,before.violations);assert.equal(after.cleared,before.cleared);assert.ok(after.lesson?.message);ui.button('重播');}
let ruleTurns=0;
while(ui.api.buttons().some(b=>b.label==='下一段'&&b.enabled)){ui.button('下一段');assert.ok(++ruleTurns<15);}
assert.ok(ui.api.buttons().every(b=>b.y>=0&&b.y+b.h<=812),'规则按钮不能越界');
ui.button('返回游戏');
ui.cell(solution[0]);
if (solution.length > 1)
ui.cell(solution[1]);
const originalFills=JSON.stringify(ui.api.snapshot().cellViews);
ui.button('撤销');
assert.equal(ui.api.snapshot().path.length, 1);
ui.button('重开');
assert.equal(ui.api.snapshot().path.length, 0);
ui.button('指路');
const hinted=ui.api.snapshot().hint;
assert.ok(hinted?.route.length>=2);
assert.equal(ui.api.snapshot().path.length,0);
for (const [step,id] of solution.entries()) {
const playedBefore=ui.played.length;ui.cell(id);
if(step===solution.length-1)assert.deepEqual(ui.played.slice(playedBefore),['audio/complete.wav'],'最后一步只播放一次过关音,不叠加路径点音');
if(step===1)assert.equal(JSON.stringify(ui.api.snapshot().cellViews),originalFills,'点击指路前后玩家填色必须一致 '+l.id);
assert.equal(Boolean(ui.api.snapshot().hint),step<hinted.end,'到达段末后消失');
}
const s = ui.api.snapshot();
assert.equal(s.cleared, true, '无法触摸通关 ' + l.id);
assert.equal(s.violations.length, 0);
const action=ui.api.buttons().find(b=>b.label===(i===199?'返回选关':'下一关 →'));
assert.ok(action,'通关按钮必须存在');
assert.ok(!ui.api.buttons().some(b=>['重开','撤销','指路'].includes(b.label)),'通关按钮独占操作栏');
const board=ui.images.at(-1);assert.ok(action.y>=board.y+board.h+24,'下一关不能覆盖盘面');
rows.push({ order: i + 1, id: l.id, geometry: l.geometry, cells: l.cells.length, unique: true, touchCleared: true });
}
ui.handlers.Hide();
assert.equal(ui.frames.size, 0);
ui.handlers.Show();
assert.equal(ui.frames.size, 1);
ui.tick();
assert.ok(ui.played.length>500,'发布包必须实际调用原生音频播放');
assert.ok(!ui.api.snapshot().rules.includes('共边'));
const restored = boot(ui.storage, 320, 640);
assert.equal(restored.api.snapshot().level, ordered[199].id);
assert.equal(JSON.parse(ui.storage.get('variant:campaign-200-r2:v1')).length, 200);
restored.button('规则');assert.ok(restored.api.buttons().every(b=>b.y>=0&&b.y+b.h<=640));const smallBefore=restored.api.snapshot();restored.advance(5200);assert.deepEqual(restored.api.snapshot().path,smallBefore.path);restored.button('返回游戏');
restored.button('音效开');
const reopen = boot(ui.storage);
assert.equal(reopen.api.snapshot().soundEnabled, false);
// 在多种手机尺寸下验证通关后的真实按钮、绘制区域与点击跳转。
for(const [w,h]of [[320,568],[375,667],[390,844],[414,896]]){
const phone=boot(new Map(),w,h);
for(const i of [0,28,34,99,198,199]){
phone.select(i);for(const id of solve(ordered[i],{limit:1})[0])phone.cell(id);
const buttons=phone.api.buttons(),board=phone.images.at(-1),action=buttons.find(b=>b.label===(i===199?'返回选关':'下一关 →'));
assert.ok(action.y>=board.y+board.h+24,w+'x'+h+' 不覆盖盘面');
assert.ok(action.x>=0&&action.x+action.w<=w&&action.y+action.h<=h-34,'不能进入底部安全区');
for(let a=0;a<buttons.length;a++)for(let b=a+1;b<buttons.length;b++){const x=buttons[a],y=buttons[b];assert.ok(x.x+x.w<=y.x||y.x+y.w<=x.x||x.y+x.h<=y.y||y.y+y.h<=x.y,'按钮不能相叠');}
phone.button(action.label);
if(i===199)assert.equal(phone.api.snapshot().screen,'select');else{assert.equal(phone.api.snapshot().displayOrder,i+2);assert.ok(phone.api.buttons().some(b=>b.label==='重开'||b.label==='返回游戏'));}
}
}
const narrow=boot(new Map(),320,568);
for(const l of ordered.filter(l=>l.tutorial)){narrow.select(ordered.indexOf(l));narrow.button('规则');let pages=0;do{assert.ok(narrow.api.buttons().every(b=>b.y>=0&&b.y+b.h<=568));const before=narrow.api.snapshot();narrow.advance(3900);const after=narrow.api.snapshot();for(const field of ['path','centers','doors','latchedDoors','records','lastSound'])assert.deepEqual(after[field],before[field],field);if(!narrow.api.buttons().some(b=>b.label==='下一段'&&b.enabled))break;narrow.button('下一段');assert.ok(++pages<30);}while(true);narrow.button('返回游戏');}
const idle=boot();if(idle.api.buttons().some(b=>b.label==='返回游戏'))idle.button('返回游戏');idle.button('选关');idle.advance(1000);assert.equal(idle.frames.size,0,'静态选关页不持续绘制');
assert.ok(Object.values(idle.api.snapshot().fonts).every(v=>v==='loaded'));
idle.button('设置');idle.button('减少动态:关');idle.button('完成');
assert.equal(idle.api.snapshot().reducedMotion,true);
idle.select(162);idle.cell(solve(ordered[162])[0][0]);idle.advance(1000);
assert.equal(idle.frames.size,0,'减少动态的思考阶段不持续绘制');
idle.button('指路');idle.advance(1000);assert.ok(idle.api.snapshot().hint);assert.equal(idle.frames.size,0,'静态指路仍保留信息但不循环绘制');
const reducedRestored=boot(idle.storage);assert.equal(reducedRestored.api.snapshot().reducedMotion,true);
const result = {startupGameplay:true,chapterSelection:true,theme:'bright-classroom',bundledFonts:true,reducedMotionPersisted:true,idleRenderingStops:true,segmentHints:true,playerFillUnchangedAfterHint:true,animatedLessons:10,previewStateIsolation:true,narrowTutorials:'320x568', source: manifest.source, version: pack.version, total: 200, passed: 200, sourceHashesMatch: true, uniqueSolution: true, simulatedWeChatTouch: true, undoRestartHint: true, storageRestore: true, foregroundLoop: true, nativeAudioPlaybackCalls: ui.played.length, plainLanguageRules: true, pagedRules: true, clearActionLayout: true, phoneSizesChecked: 4, rows };
fs.writeFileSync(path.join(root, 'validation.json'), JSON.stringify(result, null, 2));
console.log('PASS 200/200: r2源文件哈希一致、唯一解、微信发布包触摸通关、撤销/重开/指路、存档/音效设置恢复、前后台切换。');