配对码与认领(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 在途)
300 lines
16 KiB
JavaScript
300 lines
16 KiB
JavaScript
#!/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('全部通过。');
|