// 设置模块单元测试:默认布局 / 应用定位 / 进入 / 确定保存 / 恢复默认 const noop = () => { }; class El { constructor(tag = 'DIV') { this.tagName = tag.toUpperCase(); this.value = ''; this.textContent = ''; this._html = ''; this._classes = new Set(); this._style = {}; this.dataset = {}; this.children = []; this._ev = {}; this.parent = null; const self = this; this.classList = { add: c => self._classes.add(c), remove: c => self._classes.delete(c), contains: c => self._classes.has(c), toggle: (c, f) => { const on = f === undefined ? !self._classes.has(c) : f; on ? self._classes.add(c) : self._classes.delete(c); return on; }, }; } get style() { return this._style; } set className(v) { this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); } get className() { return [...this._classes].join(' '); } set innerHTML(v) { this._html = v; if (v === '') this.children = []; } get innerHTML() { return this._html; } appendChild(c) { c.parent = this; this.children.push(c); return c; } remove() { if (this.parent) { const i = this.parent.children.indexOf(this); if (i >= 0) this.parent.children.splice(i, 1); } } querySelector() { return new El(); } querySelectorAll(sel) { return sel === '.tc-btn' ? this.children.filter(c => c._classes.has('tc-btn')) : []; } closest(sel) { const cls = sel.replace(/^\./, ''); let n = this; while (n) { if (n._classes && n._classes.has(cls)) return n; n = n.parent; } return null; } addEventListener(type, fn) { (this._ev[type] ||= []).push(fn); } removeEventListener() { } dispatch(type, ev) { (this._ev[type] || []).forEach(fn => fn(ev)); } setPointerCapture() { } releasePointerCapture() { } getBoundingClientRect() { return { left: 0, top: 0, width: 800, height: 608 }; } } const byId = new Map(); const touchMap = new Map(); const docListeners = {}; globalThis.localStorage = { _d: {}, getItem(k) { return k in this._d ? this._d[k] : null; }, setItem(k, v) { this._d[k] = String(v); }, removeItem(k) { delete this._d[k]; }, }; globalThis.document = { getElementById(id) { if (!byId.has(id)) byId.set(id, new El()); return byId.get(id); }, createElement(t) { return new El(t); }, querySelector(sel) { const m = sel.match(/data-touch="([^"]+)"/); if (m) { const k = m[1]; if (!touchMap.has(k)) touchMap.set(k, new El()); return touchMap.get(k); } return new El(); }, querySelectorAll() { return []; }, addEventListener(type, fn) { (docListeners[type] ||= []).push(fn); }, removeEventListener() { }, dispatch(type, ev) { (docListeners[type] || []).forEach(fn => fn(ev)); }, }; globalThis.window = { addEventListener: noop, removeEventListener: noop }; globalThis.addEventListener = (t, fn) => { (docListeners[t] ||= []).push(fn); }; globalThis.removeEventListener = noop; globalThis.confirm = () => true; let fail = 0; const ok = (c, m) => { console.log((c ? ' ✔ ' : ' ✗ ') + m); if (!c) fail++; }; const { Save } = await import('../js/save.js'); const { Settings } = await import('../js/settings.js'); const { applyBindings, defaultBindings } = await import('../js/input.js'); console.log('=== input 键位 API ==='); ok(defaultBindings().left.includes('ArrowLeft'), 'defaultBindings 含出厂左移键'); applyBindings({ left: ['KeyA'], jump: ['Space'] }); ok(true, 'applyBindings 调用不报错'); console.log('=== 初始化 / 默认布局(PC 模式)==='); Settings.init(false); const s0 = Save.data.settings; ok(!!s0 && !!s0.touch, '存档含 settings.touch'); const keys = ['left', 'right', 'jump', 'shoot', 'down', 'pause', 'suicide']; ok(keys.every(k => s0.touch[k]), '7 个手柄键都有默认布局'); ok(s0.touch.left.cx === 12 && s0.touch.jump.size === 94, '默认坐标/尺寸正确'); ok(byId.get('title-user').textContent === '玩家:无名氏', '标题默认显示「无名氏」'); console.log('=== apply 把布局写到真实手柄 ==='); const L = touchMap.get('left'), J = touchMap.get('jump'); ok(L._style.left === '12%' && L._style.top === '80%' && L._style.width === '62px', 'left 按钮定位/尺寸已应用'); ok(J._style.width === '94px' && J._style.left === '88%', 'jump 按钮定位/尺寸已应用'); console.log('=== 进入 / 确定 保存用户名与布局 ==='); Settings.enter(); byId.get('set-username').value = '阿测玩家'; Settings.confirm(); ok(Save.data.settings.username === '阿测玩家', '用户名已保存到存档'); ok(byId.get('title-user').textContent === '玩家:阿测玩家', '标题用户名已更新'); ok(localStorage.getItem('iwbtc_fan_v1').includes('阿测玩家'), '已写入 localStorage'); console.log('=== 恢复默认 ==='); byId.get('set-username').value = '随便'; Settings.reset(); ok(Settings._state().username === '', '恢复默认后用户名为空'); ok(Settings._state().touch.jump.size === 94 && Settings._state().touch.left.cx === 12, '恢复默认后布局回到出厂值'); // 恢复默认后必须再点确定才落盘 ok(Save.data.settings.username === '阿测玩家', '恢复默认未自动落盘(需点确定)'); Settings.confirm(); ok(Save.data.settings.username === '', '点确定后空用户名落盘'); console.log('=== 布局越长越界会被限制(place 约束在交互层,这里校验默认在合法区间)==='); ok(keys.every(k => { const p = Save.data.settings.touch[k]; return p.cx >= 4 && p.cx <= 96 && p.size >= 24 && p.size <= 160; }), '默认布局均在合法范围'); console.log('=== UI SCREENS 注册(防止设置屏永远不显示)==='); const uiSrc = (await import('node:fs')).readFileSync(new URL('../js/ui.js', import.meta.url), 'utf8'); ok(/SCREENS\s*=\s*\[[^\]]*'scr-settings'/.test(uiSrc), 'scr-settings 已加入 SCREENS 列表'); ok(/data-act="settings"/.test((await import('node:fs')).readFileSync(new URL('../index.html', import.meta.url), 'utf8')), '标题菜单有「设置」入口'); console.log('=== 模拟拖动 / 缩放(防「按下即跳角」)==='); Settings.enter(); // 重新同步工作副本 const ev = (t, x, y) => ({ target: t, clientX: x, clientY: y, pointerId: 1, preventDefault: noop, stopPropagation: noop }); const left = Settings._els().left; const handle = left.children.find(c => c._classes.has('tc-rsz')) || left.children[0]; const wt = Settings._state().touch; const cx0 = wt.left.cx, cy0 = wt.left.cy; // 在按钮中心 (96,486) 按下,向右拖 40px(预览宽 800 → +5%) left.dispatch('pointerdown', ev(left, 96, 486)); left.dispatch('pointermove', ev(left, 136, 486)); ok(Math.abs(wt.left.cx - (cx0 + 5)) < 0.01 && wt.left.cy === cy0, '拖动:cx 按位移增量变化,无跳角'); // 再拖回原处 left.dispatch('pointermove', ev(left, 96, 486)); ok(Math.abs(wt.left.cx - cx0) < 0.01, '拖动:逆向位移可精确归位'); left.dispatch('pointerup', ev(left, 96, 486)); // 缩放手柄:中心 (96,486),起点距中心 ~56,拉远到 ~113 → 尺寸翻倍 const size0 = wt.left.size; handle.dispatch('pointerdown', ev(handle, 136, 526)); handle.dispatch('pointermove', ev(handle, 176, 566)); ok(wt.left.size > size0 && wt.left.size <= 160, `缩放:尺寸随距离放大(${size0}→${wt.left.size.toFixed(0)})`); handle.dispatch('pointerup', ev(handle, 176, 566)); ok(wt.left.cx === cx0 && wt.left.cy === cy0, '缩放:不改变按钮位置'); console.log('=== 设备模式:PC 显示键盘映射,移动端显示手柄 ==='); Settings.init(true); // isTouch=true → 移动端 Settings.enter(); ok(byId.get('set-keys-row')._classes.has('hidden'), '移动端:键盘映射面板隐藏'); ok(!byId.get('set-touch-row')._classes.has('hidden'), '移动端:手柄面板显示'); Settings.init(false); // PC Settings.enter(); ok(!byId.get('set-keys-row')._classes.has('hidden'), 'PC:键盘映射面板显示'); ok(byId.get('set-touch-row')._classes.has('hidden'), 'PC:手柄面板隐藏'); console.log('=== 键盘重绑:点击按键 → 按新键 ==='); Settings.enter(); const keysBox = byId.get('set-keys'); const firstRow = keysBox.children[0]; // 左移行 const kkeys = firstRow.children[1]; // kkeys 容器 const chip = kkeys.children[0]; // 第一个按键 chip(left[0]) ok(chip.dataset.kact === 'left' && String(chip.dataset.kidx) === '0', '首行首键为 left[0]'); keysBox.dispatch('click', { target: chip }); // 进入重绑监听 document.dispatch('keydown', { code: 'KeyB', preventDefault: noop }); // KeyB 未被任何动作占用 ok(Settings._state().keys.left[0] === 'KeyB', '重绑:left[0] 变为空闲键 KeyB'); // 添加第二个键 const before = Settings._state().keys.left.length; const addBtn = kkeys.children[kkeys.children.length - 1]; // +添加 keysBox.dispatch('click', { target: addBtn }); document.dispatch('keydown', { code: 'KeyF', preventDefault: noop }); // KeyF 空闲 ok(Settings._state().keys.left.length === before + 1 && Settings._state().keys.left.includes('KeyF'), '添加:left 多绑定了一个键 KeyF'); // 删除第一个键(即刚重绑的 KeyB) const chip1 = kkeys.children[0]; const rm = chip1.children.find(c => c._classes.has('krm')); keysBox.dispatch('click', { target: rm }); ok(!Settings._state().keys.left.includes('KeyB') && Settings._state().keys.left.includes('KeyF'), '删除:left[0] 被移除(KeyB 消失,KeyF 仍在)'); // 确定后落盘 Settings.confirm(); ok(Save.data.settings.keys.left.includes('KeyF'), '确定:键位变更已保存到存档'); console.log('=== 键盘冲突检测 ==='); Settings.reset(); // 出厂:left=[ArrowLeft,KeyA] Settings.enter(); const kb = byId.get('set-keys'); const jumpRow = kb.children[3]; // 跳跃行(KEY_ACTIONS 第 4 项,down 已移除) const jumpKeys = jumpRow.children[1]; const jumpChip = jumpKeys.children[0]; ok(jumpChip.dataset.kact === 'jump', '找到跳跃行的按键'); kb.dispatch('click', { target: jumpChip }); // 监听跳跃键 document.dispatch('keydown', { code: 'KeyA', preventDefault: noop }); // KeyA 已被左移占用 const st2 = Settings._state(); ok(!st2.keys.jump.includes('KeyA'), '冲突:jump 不会绑定到被【左移】占用的 KeyA'); ok(Settings._conflict() && Settings._conflict().act === 'jump' && Settings._conflict().label === '左移', '冲突:记录到被【左移】占用'); ok(kb.children[0] && kb.children[0]._classes.has('kwarb'), '冲突:面板顶部显示警告横幅'); document.dispatch('keydown', { code: 'KeyK', preventDefault: noop }); // 换空闲键 ok(st2.keys.jump[0] === 'KeyK', '冲突解除:改用空闲键 KeyK 成功'); ok(Settings._conflict() === null, '冲突:解除后警告清空'); // 同一动作内重复键也算冲突(jump 已含 KeyK,再绑到第二个槽应被拒) kb.dispatch('click', { target: jumpKeys.children[jumpKeys.children.length - 1] }); // +添加 document.dispatch('keydown', { code: 'KeyK', preventDefault: noop }); ok(st2.keys.jump.filter(c => c === 'KeyK').length === 1, '冲突:同一动作内重复键不重复写入'); // Esc 取消监听 kb.dispatch('click', { target: jumpChip }); document.dispatch('keydown', { code: 'Escape', preventDefault: noop }); ok(Settings._listening() === null && Settings._conflict() === null, 'Esc:退出监听且无残留警告'); // 系统动作(菜单 KeyQ)也算冲突 kb.dispatch('click', { target: jumpChip }); document.dispatch('keydown', { code: 'KeyQ', preventDefault: noop }); ok(Settings._conflict() && Settings._conflict().label === '菜单', '冲突:KeyQ 已被系统「菜单」占用'); console.log('=== 恢复默认:键位回到出厂 ==='); Settings.reset(); ok(Settings._state().keys.left.includes('ArrowLeft'), '恢复默认:left 含出厂 ArrowLeft'); ok(Settings._state().keys.jump.includes('ShiftLeft'), '恢复默认:jump 含出厂 ShiftLeft'); console.log('=== 回归:main.js 中「确定」应返回上一页 ==='); const mainSrc = (await import('node:fs')).readFileSync(new URL('../js/main.js', import.meta.url), 'utf8'); ok(/settings:\s*\(\)\s*=>\s*\{\s*settingsFrom\s*=\s*UI\.cur/.test(mainSrc), '进入设置前记录来源界面'); ok(/'set-ok':\s*\(\)\s*=>\s*\{\s*Settings\.confirm\(\);\s*UI\.show\(settingsFrom\)/.test(mainSrc), '确定后 UI.show(settingsFrom) 返回上一页'); ok(/Settings\.init\(useTouch\)/.test(mainSrc), 'Settings.init 传入设备检测结果'); console.log(fail === 0 ? '\n✅ settings 全部断言通过' : `\n❌ 失败 ${fail} 项`); process.exit(fail === 0 ? 0 : 1);