#!/usr/bin/env node /** * engine-input.test.mjs — 引擎键盘输入通道回归(P0-A,research/platform/02-input-parity.md)。 * * 键盘通道不变式(gesture.js 与 touch/mouse 喂同一状态机,事件完全同构): * K1 方向键/WASD = 四向滑动:keydown 即发、零延迟、swipe 震动; * K2 空格 = 单击:抬起判 tap,**保留 ~300ms 双击窗**(两次空格=doubletap、 * 第一次 pending tap 被取消),窗口与触摸共享(触屏 tap + 空格 tap 也判双击); * K3 回车 = 双击专用键:即时 doubletap(不等窗),作废等待中的单击; * K4 按住空格 ≥450ms = longpress(+window 快进事件),松开 = longpressend,无 tap; * K5 守卫:e.repeat 抑制(但映射键 repeat 仍 preventDefault 防连滚)、表单焦点 * 放行、未映射键不拦截(Tab/Esc)、tracking 单飞行指针互斥; * K6 inputMode():settings.input(新增 auto/touch/key)手动优先,auto 跟随 * 最近通道(keydown→key / touchstart→touch),初始按能力检测; * K7 immediateTap(rhythm):空格按下即 tap、长按补 tapcancel、双击改判—— * 键盘路径与触摸路径行为逐条一致; * K8 detach 后键盘监听移除;孤儿 keyup 不产生事件; * K9 voice 键盘变体路由(P1 路线 a):生效模式=key 时 audio.js 候选链前置 * voice//key/(回退 gender → 平铺),touch 模式链与 P1 前一致; * 缓存键按输入模式分桶(key||)。 * * 运行:tools/node/bin/node web/harness/engine-input.test.mjs */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const WEB = path.resolve(HERE, '..'); let passed = 0; const failures = []; const assert = (cond, msg) => { if (cond) { passed++; console.log(' ✓ ' + msg); } else { failures.push(msg); console.log(' ✗ ' + msg); } }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ---- 全局 mock(在 import 引擎前就位;真实定时器 + 真实 performance.now)---- const winL = new Map(); // window 监听器(gesture.js 键盘通道挂这里) const docL = new Map(); // document 捕获监听器(合成鼠标守卫 + 输入模式跟随) const windowEvents = []; // window.dispatchEvent 收到的 CustomEvent(长按快进通道) globalThis.window = { addEventListener: (t, fn) => { if (!winL.has(t)) winL.set(t, []); winL.get(t).push(fn); }, removeEventListener: (t, fn) => { const a = winL.get(t) || []; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }, dispatchEvent: (e) => { windowEvents.push(e); return true; }, }; globalThis.document = { addEventListener: (t, fn) => { if (!docL.has(t)) docL.set(t, []); docL.get(t).push(fn); }, removeEventListener: () => {}, }; const storageMap = new Map(); globalThis.localStorage = { getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null), setItem: (k, v) => storageMap.set(k, String(v)), removeItem: (k) => storageMap.delete(k), }; const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 0, vibrate: (p) => { vibrateLog.push(p); return true; } }, // 纯桌面能力 → inputMode 初始 'key' configurable: true, }); // (无 matchMedia:isTouchLikely 走 maxTouchPoints=0 → false) const { attachGestures, inputMode } = await import(path.join(WEB, 'engine', 'gesture.js')); const { settings } = await import(path.join(WEB, 'engine', 'settings.js')); // 等进程时钟走过一个双击窗:全新 Node 进程 performance.now() 可能 < 300ms, // 引擎 lastTapTime 初值 0 会让第一次点按被误判进双击窗(浏览器 page uptime // 不会如此早,纯测试环境假象)。 await sleep(350); // ---- 事件合成 ---- const BODY_TARGET = { tagName: 'BODY' }; function fireKey(type, init = {}) { const ev = { code: '', repeat: false, target: BODY_TARGET, prevented: false, preventDefault() { this.prevented = true; }, ...init }; for (const fn of winL.get(type) || []) fn(ev); return ev; } const keyDown = (code, extra) => fireKey('keydown', { code, ...extra }); const keyUp = (code, extra) => fireKey('keyup', { code, ...extra }); function fireDoc(type) { for (const fn of docL.get(type) || []) fn({}); } function makeRoot() { const listeners = new Map(); return { listeners, addEventListener: (t, fn) => { if (!listeners.has(t)) listeners.set(t, []); listeners.get(t).push(fn); }, removeEventListener: (t, fn) => { const a = listeners.get(t) || []; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }, getBoundingClientRect: () => ({ left: 0, top: 0, width: 390, height: 844 }), }; } /** 在元素上合成一次触摸(tap:down→up 同坐标,走真实 onTouchStart/End)。 */ function touchTap(root, x = 195, y = 422) { const mk = () => ({ preventDefault() {} }); const down = { ...mk(), touches: [{ clientX: x, clientY: y }] }; const up = { ...mk(), touches: [], changedTouches: [{ clientX: x, clientY: y }] }; fireDoc('touchstart'); // document 捕获(输入模式跟随) for (const fn of root.listeners.get('touchstart') || []) fn(down); for (const fn of root.listeners.get('touchend') || []) fn(up); } // ---------------------------------------------------------------- 会话 const root = makeRoot(); const events = []; const vib = () => JSON.stringify(vibrateLog.slice(-1)[0] || null); const gest = (g) => events.push(g); const detach = attachGestures(root, gest); console.log('\n=== K6a settings.input 默认与 inputMode 初始 ==='); assert(settings.get().input === 'auto', 'settings.input 默认 auto'); assert(settings.set({ input: 'bogus' }).input === 'auto', '非法 input 值被忽略(VALID 枚举)'); assert(inputMode() === 'key', 'auto 且无输入历史:纯桌面能力检测 → key'); console.log('\n=== K1 方向键 / WASD = 四向滑动(即时) ==='); let ev = keyDown('ArrowLeft'); keyUp('ArrowLeft'); assert(events.pop() === 'left' && events.length === 0, '← 即发 swipeleft(无延迟)'); assert(vib() === '[10]', '方向键滑动 → swipe 震动 [10]'); keyDown('ArrowRight'); assert(events.pop() === 'right', '→ = right'); keyDown('ArrowUp'); assert(events.pop() === 'up', '↑ = up'); keyDown('ArrowDown'); assert(events.pop() === 'down', '↓ = down'); keyDown('KeyW'); keyUp('KeyW'); assert(events.pop() === 'up', 'W 别名 = up'); keyDown('KeyA'); assert(events.pop() === 'left', 'A 别名 = left'); keyDown('KeyS'); assert(events.pop() === 'down', 'S 别名 = down'); keyDown('KeyD'); assert(events.pop() === 'right', 'D 别名 = right'); assert(ev.prevented === true, '映射键 keydown 被 preventDefault(防页面滚动)'); console.log('\n=== K5a repeat 抑制 / 未映射键放行 ==='); ev = keyDown('ArrowUp', { repeat: true }); assert(events.length === 0, 'e.repeat 方向键不重发手势'); assert(ev.prevented === true, 'repeat 仍 preventDefault(按住方向键不连滚页面)'); ev = keyDown('Tab'); assert(events.length === 0 && ev.prevented === false, 'Tab 不拦截(无障碍键放行)'); keyDown('Escape'); assert(events.length === 0, 'Esc 无映射(裁定:不做 Esc,下滑已覆盖菜单)'); ev = keyDown('Space', { repeat: true }); assert(events.length === 0 && ev.prevented === true, '空格 repeat 抑制且防滚动'); keyUp('Space'); assert(events.length === 0, '被抑制的空格 repeat 不会被 keyup 认领(无孤儿 tap)'); console.log('\n=== K2 空格 = 单击(~300ms 双击窗,与触屏同感) ==='); keyDown('Space'); keyUp('Space'); assert(events.length === 0, '空格快按后不立即出 tap(等双击窗,与触摸一致)'); await sleep(380); assert(events.pop() === 'tap' && events.length === 0, '~300ms 双击窗关闭后回调 tap'); assert(vib() === '[20]', 'tap → confirm 震动 [20]'); keyDown('Space'); keyUp('Space'); await sleep(400); keyDown('Space'); keyUp('Space'); await sleep(380); assert(events.length === 2 && events[0] === 'tap' && events[1] === 'tap', '慢速两连空格 = 两个独立 tap'); events.length = 0; keyDown('Space'); keyUp('Space'); keyDown('Space'); keyUp('Space'); assert(events.pop() === 'doubletap' && events.length === 0, '快速两连空格 = doubletap(即时改判)'); await sleep(400); assert(events.length === 0, '第一次空格的 pending tap 被取消(无双击窗泄漏的 tap)'); console.log('\n=== K3 回车 = 双击专用键(即时,不等窗) ==='); keyDown('Enter'); keyUp('Enter'); assert(events.pop() === 'doubletap' && events.length === 0, '回车同 tick 即时 doubletap(零延迟)'); assert(vib() === '[20]', '回车 doubletap → confirm 震动'); keyDown('Space'); keyUp('Space'); // 空格 tap 进入 300ms 等待 await sleep(100); keyDown('Enter'); keyUp('Enter'); // 窗内回车 assert(events.pop() === 'doubletap', '窗内回车 → doubletap'); await sleep(450); assert(events.length === 0, '等待中的空格单击被回车作废(cancelPendingTap)'); ev = keyDown('Enter', { repeat: true }); assert(events.length === 0 && ev.prevented === true, '回车 repeat 抑制'); console.log('\n=== K4 按住空格 ≥450ms = 长按 ==='); keyDown('Space'); await sleep(520); assert(events.pop() === 'longpress' && events.length === 0, '按住空格 ~450ms 触发 longpress'); assert(windowEvents.some((e) => e.type === 'soundgame:longpress'), "派发 window 'soundgame:longpress'(旁白快进通道)"); keyUp('Space'); assert(events.pop() === 'longpressend' && events.length === 0, '松开 → longpressend'); assert(windowEvents.some((e) => e.type === 'soundgame:longpressend'), "派发 window 'soundgame:longpressend'"); await sleep(400); assert(events.length === 0, '长按后不触发 tap/doubletap'); keyDown('Space'); await sleep(380); // < 450ms keyUp('Space'); assert(events.length === 0, '不足 450ms 的按住不是长按(先不发包)'); await sleep(380); assert(events.pop() === 'tap', '短于阈值的按住仍走单击(380ms < 450ms)'); console.log('\n=== K5b 表单焦点守卫 ==='); for (const target of [{ tagName: 'INPUT' }, { tagName: 'TEXTAREA' }, { tagName: 'SELECT' }, { isContentEditable: true }]) { ev = keyDown('Space', { target }); assert(events.length === 0 && ev.prevented === false, `表单焦点(${target.tagName || 'contenteditable'})放行:不吞键不拦截`); } keyUp('Space'); await sleep(400); assert(events.length === 0, '表单内按下的空格 keyup 不产生孤儿 tap'); ev = keyDown('ArrowLeft', { target: { tagName: 'INPUT' } }); assert(events.length === 0 && ev.prevented === false, '表单内方向键放行(输入框光标移动正常)'); console.log('\n=== K5c tracking 单飞行指针互斥 ==='); keyDown('Space'); // 空格虚拟指针占用 tracking keyDown('ArrowLeft'); keyUp('ArrowLeft'); keyDown('Enter'); keyUp('Enter'); assert(events.length === 0, '空格按住期间方向键/回车被忽略(单指针语义,与多指忽略一致)'); keyUp('Space'); await sleep(380); assert(events.pop() === 'tap', '占用结束后空格自身仍正常结算 tap'); keyDown('ArrowRight'); assert(events.pop() === 'right', '抬起后方向键恢复工作'); console.log('\n=== K2b 触摸 + 键盘共享双击窗(同一虚拟指针) ==='); touchTap(root); // 触摸 tap → pending keyDown('Space'); keyUp('Space'); // 300ms 内空格 tap assert(events.pop() === 'doubletap' && events.length === 0, '触屏 tap + 空格 tap 判 doubletap(混合设备语义)'); await sleep(400); assert(events.length === 0, '触摸那一下的 pending tap 同样被取消'); console.log('\n=== K7 immediateTap(rhythm:空格=演奏输入,零改动成立) ==='); const root2 = makeRoot(); const events2 = []; const detach2 = attachGestures(root2, (g) => events2.push(g), { immediateTap: true }); keyDown('Space'); assert(events2.pop() === 'tap' && events2.length === 0, 'immediateTap:空格按下即发 tap(kick)'); await sleep(520); assert(events2.pop() === 'longpress' && events2.pop() === 'tapcancel' && events2.length === 0, '按住 → 序曲 tap 撤回(tapcancel)+ longpress'); keyUp('Space'); assert(events2.pop() === 'longpressend', '松开 → longpressend'); keyDown('Space'); keyUp('Space'); keyDown('Space'); keyUp('Space'); assert(events2.length === 3 && events2[0] === 'tap' && events2[1] === 'tap' && events2[2] === 'doubletap', '两连空格 = tap+tap+doubletap 改判(双击序曲不发 tapcancel,与触摸一致)'); events2.length = 0; keyDown('Enter'); assert(events2.pop() === 'doubletap', 'immediateTap 模式回车同样即时 doubletap'); detach2(); console.log('\n=== K6b inputMode 运行时跟随 + 手动覆盖 ==='); fireDoc('touchstart'); assert(inputMode() === 'touch', 'auto:touchstart → 提示切触摸版'); keyDown('ArrowDown'); assert(inputMode() === 'key', 'auto:键盘 keydown → 提示切键盘版(最近使用优先)'); settings.set({ input: 'touch' }); assert(inputMode() === 'touch', 'settings.input=touch 手动覆盖优先'); settings.set({ input: 'key' }); assert(inputMode() === 'key', 'settings.input=key 手动覆盖优先'); settings.set({ input: 'auto' }); assert(inputMode() === 'key', '回 auto 后恢复跟随最近通道(=key)'); console.log('\n=== K8 detach / 孤儿事件 ==='); events.length = 0; // K6b 的方向键判定已断言过,清场 detach(); keyDown('ArrowLeft'); keyDown('Space'); keyUp('Space'); keyDown('Enter'); assert(events.length === 0, 'detach 后键盘监听已移除(无手势泄漏)'); await sleep(400); assert(events.length === 0, 'detach 同时清掉挂起的定时器(无迟到 tap)'); keyUp('Space'); assert(events.length === 0, '孤儿空格 keyup(无对应认领)无事件无异常'); // ---- K9 voice 键盘变体资产路由(P1 路线 a,engine/audio.js _candidates/_load)---- console.log('\n=== K9 voice 键盘变体路由(key 候选前置 + 回退链) ==='); { const detach9 = attachGestures(root, () => {}); // K8 已 detach:重新挂上才能用 keyDown 驱动 inputMode 跟随 const { WebAudioEngine } = await import(path.join(WEB, 'engine', 'audio.js')); const engine = new WebAudioEngine('audio/'); settings.set({ input: 'auto' }); keyDown('ArrowUp'); // 初始即 key(纯桌面),按键再确认一次 assert(inputMode() === 'key', 'auto + 纯桌面 → 生效模式 key'); let c = engine._candidates('voice/help'); assert(c[0] === 'audio/voice/female/key/help.ogg', 'key 模式:候选链首跳 voice//key/.ogg'); assert(c[4] === 'audio/voice/female/help.ogg', 'key 模式:第二跳回 voice//(主资产)'); assert(c[8] === 'audio/voice/help.ogg', 'key 模式:第三跳平铺回退(触屏/角色句兜底)'); assert(c.length === 12, 'key 模式:3 跳 × 4 扩展名 = 12 个候选'); c = engine._candidates('voice/help.ogg'); assert(c.length === 3 && c[0].endsWith('/key/help.ogg') && c[1].endsWith('/female/help.ogg'), 'key 模式:带扩展名 id 同样三跳,key 在最前'); settings.set({ input: 'touch' }); c = engine._candidates('voice/help'); assert(!c.some((u) => u.includes('/key/')) && c[0] === 'audio/voice/female/help.ogg' && c.length === 8, 'touch 模式:无 key 跳,候选链与 P1 前完全一致(gender → flat × 4 扩展)'); settings.set({ input: 'auto' }); fireDoc('touchstart'); c = engine._candidates('voice/help'); assert(!c.some((u) => u.includes('/key/')), 'auto 运行时跟随:触摸后变体跳消失'); keyDown('ArrowUp'); c = engine._candidates('voice/help'); assert(c[0] === 'audio/voice/female/key/help.ogg', 'auto 运行时跟随:键盘一按变体跳回来(语音随最近通道切措辞)'); // 缓存键按生效模式分桶:key/main 两套结果互不串缓存(与性别切换同语义) engine._cache.clear(); await engine._load('voice/help'); // ctx 为 null → 直接 null,但仍写入缓存 const keys = [...engine._cache.keys()]; assert(keys.length === 1 && keys[0] === 'key|female|voice/help', '缓存键带输入模式:key 模式下为 key||'); fireDoc('touchstart'); await engine._load('voice/help'); assert([...engine._cache.keys()].some((k) => k === 'main|female|voice/help'), '切回 touch 后同一 id 落独立 main|| 缓存桶'); detach9(); } console.log(`\n========================================`); console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`); if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); } console.log('全部通过。');