/** * compat-pipeline.test.mjs — 兼容输入管道单测(26 号 §4.2 / §5 P0 推广层)。 * * 覆盖: * - 手势后游戏有内容音 → 不叠回执(§4.2「已有明显内容音可省 ok」); * - 手势后 100ms 内无任何音频调度 → invalid 回执(消灭无声操作,原则 4); * - 语音播放中的手势 → 立即 ok 回执(§4.1 打断即收到); * - 语音中 tap 的 400ms 防连点(§4.2); * - longpressend / tapcancel 收尾噪声直通,不吃回执; * - audio.playCount 探针计数。 * * 运行:tools/node/bin/node web/harness/compat-pipeline.test.mjs */ import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', 'simon'); const realLog = console.log.bind(console); let passCount = 0; const failures = []; function assert(cond, msg) { if (cond) { passCount++; realLog(` ✓ ${msg}`); } else { failures.push(msg); realLog(` ✗ ${msg}`); } } console.error = () => {}; console.warn = () => {}; console.log = () => {}; let now = 0; let timerSeq = 1; const timers = new Map(); const realSetImmediate = setImmediate; globalThis.setTimeout = (cb, ms = 0, ...args) => { const id = timerSeq++; timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args }); return id; }; globalThis.clearTimeout = (id) => { timers.delete(id); }; Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true }); async function flush(rounds = 6) { for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); } async function advance(cond = null) { for (let n = 0; n < 10000; n++) { await flush(2); if (cond && cond()) return 'cond'; let nextId = -1; let nextAt = Infinity; for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } if (nextId < 0) return 'idle'; now = nextAt; const t = timers.get(nextId); timers.delete(nextId); t.cb(...t.args); } throw new Error('虚拟时钟超上限'); } globalThis.window = { addEventListener: () => {}, AudioContext: null }; const storageData = new Map(); globalThis.localStorage = { getItem: (k) => (storageData.has(k) ? storageData.get(k) : null), setItem: (k, v) => { storageData.set(k, String(v)); }, removeItem: (k) => { storageData.delete(k); }, }; Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 1, vibrate: () => true }, configurable: true, }); class MockParam { constructor(v = 1) { this.value = v; } setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {} } class MockGainNode { constructor() { this.gain = new MockParam(); } connect() {} disconnect() {} } class MockAudioContext { constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; } createGain() { return new MockGainNode(); } createDynamicsCompressor() { const n = new MockGainNode(); n.threshold = new MockParam(); n.ratio = new MockParam(); n.attack = new MockParam(); n.release = new MockParam(); return n; } createBufferSource() { return { buffer: null, loop: false, connect() {}, start() {}, stop() {} }; } createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; } decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); } resume() { this.state = 'running'; return Promise.resolve(); } } globalThis.window.AudioContext = MockAudioContext; globalThis.fetch = async (u) => { const p = path.join(GAME_DIR, decodeURIComponent(String(u))); if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; const data = fs.readFileSync(p); return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) }; }; const { WebAudioEngine } = await import('../engine/audio.js'); const { settings } = await import('../engine/settings.js'); const { makeCompatPipeline } = await import('../engine/pipeline.js'); const { Receipts } = await import('../engine/receipts.js'); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); const audio = new WebAudioEngine('audio/'); await audio.unlock(); const receipts = new Receipts(audio); const receiptLog = []; const origPlay = receipts.play.bind(receipts); receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origPlay(kind); }; // 假游戏:按手势决定是否有内容音 const fakeGame = { lastGesture: null, loudGestures: new Set(), // 这些手势会立即播 sfx onGesture(g) { this.lastGesture = g; if (this.loudGestures.has(g)) audio.play('sfx/tone_tap'); return undefined; // 旧游戏通常无返回值 }, }; const pipe = makeCompatPipeline({ audio, receipts, game: fakeGame }); realLog('== 兼容管道(P0 推广层)=='); { // 1. 内容音即反馈:不叠回执 fakeGame.loudGestures.add('left'); const n0 = receiptLog.length; pipe('left'); await flush(); await advance(() => now >= 150); assert(receiptLog.length === n0, '手势带内容音 → 不叠回执(内容音即反馈)'); // 2. 无声操作 → 100ms 时给 invalid const n1 = receiptLog.length; pipe('right'); await flush(); assert(receiptLog.length === n1, '手势后 100ms 内不抢先出声(等结果)'); await advance(() => receiptLog.length > n1); const r = receiptLog[receiptLog.length - 1]; assert(r.kind === 'invalid' && r.t <= 100 + 500, `无声手势 → invalid 回执(实际 ${r && r.kind}@${r && r.t}ms)`); // 3. playCount 探针 const c0 = audio.playCount; audio.play('sfx/tone_tap'); assert(audio.playCount === c0 + 1, 'audio.playCount 每次 play 递增(回执启发式探针)'); // 4. 语音中手势 → 立即 ok audio.play('voice/t0_new'); await flush(); assert(audio.isVoicePlaying(), '假游戏对白在播'); const n2 = receiptLog.length; pipe('up'); await flush(); assert(receiptLog.length === n2 + 1 && receiptLog[receiptLog.length - 1].kind === 'ok', '语音中的手势 → 立即 ok 回执(打断即收到)'); await advance(() => !audio.isVoicePlaying()); // 5. 语音中 tap 防连点:第二下只给 busy audio.play('voice/t0_new'); await flush(); const n3 = receiptLog.length; pipe('tap'); await flush(); assert(receiptLog[n3] && receiptLog[n3].kind === 'ok', '语音中 tap → ok'); pipe('tap'); await flush(); // 虚拟时钟未推进 = 400ms 内 assert(receiptLog[n3 + 1] && receiptLog[n3 + 1].kind === 'busy', '400ms 内第二下 tap → busy(不连跳)'); now += 500; // 跨过防连点窗口 await advance(() => !audio.isVoicePlaying()); // 6. 收尾噪声直通 const n4 = receiptLog.length; let passed = false; fakeGame.loudGestures.delete('left'); fakeGame.onGesture = (g) => { if (g === 'longpressend') passed = true; }; pipe('longpressend'); await flush(); await advance(() => now >= 150); assert(passed && receiptLog.length === n4, 'longpressend 直通游戏、不吃回执'); } realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); if (failures.length > 0) { realLog('失败清单:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');