Files
SoundGame/web/harness/engine-input.test.mjs
T
xsl 566241e05d feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)
配对码与认领(B):
- POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏
- 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化
- api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22

键盘语音双套资产(A,路线 a):
- 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原)
- 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条)
- check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成)
- voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引
- mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动

回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12)
同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
2026-09-10 00:43:23 +08:00

300 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/<gender>/key/<id>(回退 gender → 平铺),touch 模式链与 P1 前一致;
* 缓存键按输入模式分桶(key|<gender>|<id>)。
*
* 运行: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/<gender>/key/<id>.ogg');
assert(c[4] === 'audio/voice/female/help.ogg', 'key 模式:第二跳回 voice/<gender>/<id>(主资产)');
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|<gender>|<id>');
fireDoc('touchstart');
await engine._load('voice/help');
assert([...engine._cache.keys()].some((k) => k === 'main|female|voice/help'),
'切回 touch 后同一 id 落独立 main|<gender>|<id> 缓存桶');
detach9();
}
console.log(`\n========================================`);
console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`);
if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); }
console.log('全部通过。');