Files
SoundGame/web/harness/engine-input.test.mjs
T
xsl ede522dda1 feat(platform/P0): 无感用户系统 + PC 键盘双端一致(两 agent 并行 + 集成收口)
用户系统(B,游戏零改动):
- POST /api/device 首访 0 交互静默建档(自动玩家一/二…,per-IP 频控);断网降级纯本地照玩
- 大厅档案切换器(切换=flush 上云→快照回灌→倒灌→服务器回拉,profileId 分桶修同设备串档;aria menu 编号可听)
- claim 可选认领(命名+scrypt PIN);旧 users/sessions 单向迁移(PIN 明文即刻哈希,已真实数据验证);login 兼容+claim-merge 桶级 LWW;logout;/server/ 静态封禁
- PUT baseUpdatedAt 守卫(legacy 放行+警告);GET 无参全桶回拉(修 7/8 游戏跨设备只 PUT 不 GET)
- web/profiles.js 分桶纯函数 + api-smoke.mjs 7/7(建档/LWW/claim-merge/迁移/频控/封禁/分桶 H1-H4)

键盘双端(A,游戏代码零改动):
- gesture.js 键盘通道 +137 行:←↑↓→/WASD=滑动、空格=单击(走 300ms 双击窗与触摸共享虚拟指针)、回车=即时 doubletap、按住空格 ≥450ms=长按;repeat 抑制/表单守卫/方向键 preventDefault
- settings.input(auto/touch/key)+ gesture.inputMode();遮罩键盘解锁与文案自适应(按空格开始/轻触开始)
- 8 游戏遮罩改用自适应默认文案;大厅设置面板「操作方式」+页脚键位提示
- live control.mjs 键盘注入通道(CDP dispatchKeyEvent);engine-input.test 61 断言

回归:12 套件全绿(8 playtest + guide + engine-voice + engine-input + api-smoke 7/7);8001 服务器已重启加载新 API,/api/device 端到端验证通过

P1 待排:153 句×双性 TTS 键盘语音变体 / 6 位配对码跨设备 / 认领 UI 打磨(research/platform/00-synthesis.md §3)
2026-09-09 23:39:23 +08:00

252 lines
14 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 不产生事件。
*
* 运行: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(无对应认领)无事件无异常');
console.log(`\n========================================`);
console.log(`断言通过 ${passed} 条,失败 ${failures.length} 条`);
if (failures.length) { failures.forEach((m) => console.log('FAILED: ' + m)); process.exit(1); }
console.log('全部通过。');