Files
kidgo/tools/settings-test.mjs
T

224 lines
12 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.
// 设置模块单元测试:默认布局 / 应用定位 / 进入 / 确定保存 / 恢复默认
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);