Files
ark-stronghold-protocol/test/ui/feedback5-hotkeys.test.js
T
sganggs a1bc37ed90 feedback7 R22-LATE: while a facing is chosen, Enter follows the focus — ✕ cancels, any other button does nothing, the wheel confirms; golden: unchanged
GitHub #394 / PR #395 by @Xinglan233. The wheel's capture listener committed the previewed direction on Enter whatever
had the focus, so Tab to 准备 / 设置 / the exit button / ✕ and Enter deployed (the PR let only a focused ✕ keep its
native Enter). Now gameLogic facingEnter decides: Enter on ✕ cancels; on any other control (button, link, form field,
ARIA control) it is swallowed — the wheel already holds Space and the shortcuts; elsewhere it commits the previewed
direction, and does nothing while none is previewed. The wheel (role="dialog") takes the focus when it opens
(tabIndex -1, no focus ring over the screen), so a button focused before the drop cannot swallow the confirming Enter
and the first Tab reaches ✕; nothing restores the old focus when it closes (a restored 准备 would take the next key).

Docs: PLAYING.md's key paragraph. Tests: test/ui/feedback5-hotkeys.test.js (facingEnter and its wiring, 2 new);
test/ui/facing.e2e.test.js (SP_E2E, the PR's drag / Tab flow, 4 cases: run here in headless Chrome, 4/4 pass, 4/4 fail
on the parent — Enter on the focused exit button deployed). Golden unchanged (client only).
2026-10-08 21:20:27 +08:00

345 lines
23 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.
// 0.2.0 rebindable shortcuts (设置 → 快捷键; the community request 「快捷键可不可以自己设置」, the owner's decision of
// 2026-10-07): the key map in the settings (ui/gameLogic/shortcuts.js, ui/gameLogic/settings.js sanitizeSettings) —
// defaults = the keys of 0.1.4, sanitising and its fallback to the defaults, conflicts swapped, the one lookup every
// handler uses, the capture of a new key — the handlers with a rebound key, and the HUD's key hints read from the map.
// Browser counterpart: test/ui/hotkeys.e2e.test.js (SP_E2E=1).
import { test, describe, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf,
actionForKey, captureHotkey, facingSwallows, facingEnter, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
} from '../../public/js/ui/gameLogic.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the bindable codes, as the settings offer them
const LETTERS = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'].map((c) => `Key${c}`);
const DIGITS = [...'0123456789'].map((c) => `Digit${c}`);
const PUNCT = ['Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash'];
const NAMED = ['Space', 'Delete', 'Insert', 'Home', 'End', 'PageUp', 'PageDown'];
const BINDABLE = [...LETTERS, ...DIGITS, ...PUNCT, ...NAMED];
const RESERVED = ['Escape', 'Tab', 'Enter', 'NumpadEnter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'F1', 'F5', 'F12',
'ShiftLeft', 'ControlLeft', 'AltLeft', 'MetaLeft', 'CapsLock', 'Backspace', 'ContextMenu', 'IntlBackslash', '', 'keyq', 'constructor', '__proto__'];
const rebound = { ...DEFAULT_HOTKEYS, retreat: 'KeyW' };
describe('the key map: defaults and labels', () => {
test('the defaults are the keys of 0.1.4 (R / F / D / Q / X / Space), in the settings order', () => {
assert.deepEqual([...HOTKEY_ACTIONS], ['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']);
assert.deepEqual({ ...DEFAULT_HOTKEYS }, { refresh: 'KeyR', freeze: 'KeyF', levelUp: 'KeyD', retreat: 'KeyQ', sell: 'KeyX', ready: 'Space' });
assert.deepEqual(HOTKEY_ACTIONS.map((a) => hotkeyLabel(DEFAULT_HOTKEYS[a])), ['R', 'F', 'D', 'Q', 'X', 'Space']);
assert.ok(Object.isFrozen(DEFAULT_HOTKEYS) && Object.isFrozen(HOTKEY_ACTIONS));
assert.equal(DEFAULT_SETTINGS.keys, DEFAULT_HOTKEYS, 'the settings default is the same map');
assert.deepEqual(sanitizeSettings(null).keys, { ...DEFAULT_HOTKEYS });
assert.deepEqual(sanitizeSettings({ bgm: 0.4 }).keys, { ...DEFAULT_HOTKEYS }, 'a profile saved before 0.2.0 (no keys) keeps the old keys');
assert.equal(isDefaultHotkeys(DEFAULT_HOTKEYS), true);
assert.equal(isDefaultHotkeys(rebound), false);
});
test('which keys may be a shortcut, and how each is shown', () => {
for (const c of BINDABLE) assert.equal(isBindableCode(c), true, c);
for (const c of RESERVED) assert.equal(isBindableCode(c), false, c);
for (const v of [null, undefined, 3, {}, ['KeyQ']]) assert.equal(isBindableCode(v), false);
assert.equal(hotkeyLabel('KeyW'), 'W');
assert.equal(hotkeyLabel('Digit7'), '7');
assert.equal(hotkeyLabel('Comma'), ',');
assert.equal(hotkeyLabel('Backslash'), '\\');
assert.equal(hotkeyLabel('Space'), 'Space');
assert.equal(hotkeyLabel('Delete'), 'Delete');
assert.equal(hotkeyLabel('PageUp'), 'PageUp');
assert.equal(hotkeyLabel('Escape'), '');
assert.equal(hotkeyLabel(undefined), '');
});
});
describe('the key map: sanitising (the settings store, localStorage sp.pref.settings)', () => {
test('a valid map is kept as saved; it survives the JSON round trip of the store', () => {
const mine = { refresh: 'KeyG', freeze: 'Digit2', levelUp: 'Comma', retreat: 'KeyW', sell: 'Delete', ready: 'KeyE' };
assert.deepEqual(sanitizeHotkeys(mine), mine);
const saved = JSON.parse(JSON.stringify(sanitizeSettings({ bgm: 0.3, keys: mine })));
assert.deepEqual(sanitizeSettings(saved).keys, mine);
assert.equal(sanitizeSettings(saved).bgm, 0.3);
});
test('bad entries fall back to their default; unknown actions are dropped', () => {
assert.deepEqual(sanitizeHotkeys({ retreat: 'KeyW', sell: 'Escape', ready: 42 }), { ...DEFAULT_HOTKEYS, retreat: 'KeyW' });
assert.deepEqual(sanitizeHotkeys({ ...DEFAULT_HOTKEYS, pen: 'KeyP', toString: 'KeyT' }), { ...DEFAULT_HOTKEYS });
for (const c of RESERVED) assert.deepEqual(sanitizeHotkeys({ refresh: c }), { ...DEFAULT_HOTKEYS }, c);
// keys of the prototype are not saved keys
assert.deepEqual(sanitizeHotkeys(Object.create({ refresh: 'KeyG' })), { ...DEFAULT_HOTKEYS });
assert.deepEqual(sanitizeHotkeys(JSON.parse('{"__proto__": {"refresh": "KeyG"}, "sell": "KeyV"}')), { ...DEFAULT_HOTKEYS, sell: 'KeyV' });
});
test('bad data — not a map, or two actions on one key — gives the defaults', () => {
for (const raw of [null, undefined, 'KeyQ', 7, true, [], ['KeyW'], () => 'KeyQ']) assert.deepEqual(sanitizeHotkeys(raw), { ...DEFAULT_HOTKEYS }, String(raw));
assert.deepEqual(sanitizeHotkeys({ ...DEFAULT_HOTKEYS, sell: 'KeyQ' }), { ...DEFAULT_HOTKEYS }, 'retreat and sell on Q');
// an invalid entry whose default another action now holds is a conflict too
assert.deepEqual(sanitizeHotkeys({ refresh: 'KeyF', freeze: 'nonsense' }), { ...DEFAULT_HOTKEYS });
assert.deepEqual(sanitizeSettings({ quality: 'low', keys: { ready: 'KeyR' } }).keys, { ...DEFAULT_HOTKEYS });
assert.equal(sanitizeSettings({ quality: 'low', keys: { ready: 'KeyR' } }).quality, 'low', 'the other settings are kept');
});
});
describe('the key map: rebinding and conflicts', () => {
test('a free key just moves the action', () => {
const r = rebindHotkey(DEFAULT_HOTKEYS, 'retreat', 'KeyW');
assert.deepEqual(r, { keys: rebound, changed: true, swapped: null });
assert.deepEqual({ ...DEFAULT_HOTKEYS }, { refresh: 'KeyR', freeze: 'KeyF', levelUp: 'KeyD', retreat: 'KeyQ', sell: 'KeyX', ready: 'Space' }, 'the defaults are untouched');
});
test('a key another action holds is swapped: that action takes the old key', () => {
const r = rebindHotkey(DEFAULT_HOTKEYS, 'retreat', 'KeyX');
assert.equal(r.changed, true);
assert.equal(r.swapped, 'sell');
assert.deepEqual(r.keys, { ...DEFAULT_HOTKEYS, retreat: 'KeyX', sell: 'KeyQ' });
const back = rebindHotkey(r.keys, 'ready', 'KeyQ');
assert.deepEqual(back.keys, { ...DEFAULT_HOTKEYS, retreat: 'KeyX', sell: 'Space', ready: 'KeyQ' });
assert.equal(back.swapped, 'sell');
});
test('the same key, an unknown action or a reserved key change nothing', () => {
assert.deepEqual(rebindHotkey(DEFAULT_HOTKEYS, 'retreat', 'KeyQ'), { keys: { ...DEFAULT_HOTKEYS }, changed: false, swapped: null });
for (const [action, code] of [['pen', 'KeyP'], ['escape', 'KeyE'], ['retreat', 'Escape'], ['retreat', 'Enter'], ['retreat', 'ArrowUp'], ['retreat', 'F5']]) {
assert.deepEqual(rebindHotkey(DEFAULT_HOTKEYS, action, code), { keys: { ...DEFAULT_HOTKEYS }, changed: false, swapped: null }, `${action} ${code}`);
}
});
test('whatever is rebound, every action keeps exactly one key and no key does two things', () => {
let keys = { ...DEFAULT_HOTKEYS };
let seed = 7;
const rnd = (n) => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n; };
for (let i = 0; i < 2000; i++) {
keys = rebindHotkey(keys, HOTKEY_ACTIONS[rnd(HOTKEY_ACTIONS.length)], BINDABLE[rnd(BINDABLE.length)]).keys;
assert.equal(new Set(Object.values(keys)).size, HOTKEY_ACTIONS.length);
assert.deepEqual(sanitizeHotkeys(keys), keys, 'a rebound map is a valid saved map');
}
});
});
// the matcher of 0.1.4 (ui/gameLogic/shortcuts.js before 0.2.0): `code === 'KeyR' || key === 'r'`, in this order
function oldShortcut(e) {
const code = e.code || '';
const key = typeof e.key === 'string' ? e.key.toLowerCase() : '';
if (code === 'KeyR' || key === 'r') return 'refresh';
if (code === 'KeyF' || key === 'f') return 'freeze';
if (code === 'KeyD' || key === 'd') return 'levelUp';
if (code === 'KeyQ' || key === 'q') return 'retreat';
if (code === 'KeyX' || key === 'x') return 'sell';
if (code === 'Space' || key === ' ') return 'ready';
return null;
}
describe('the one lookup (actionForKey) and shortcutFor', () => {
test('with the defaults nothing changes: the 0.1.4 matcher on QWERTY, AZERTY, Dvorak and Russian presses', () => {
const qwerty = [...LETTERS.map((c) => ({ code: c, key: c.slice(3).toLowerCase() })), ...DIGITS.map((c) => ({ code: c, key: c.slice(5) })), { code: 'Space', key: ' ' }];
// AZERTY: A↔Q, Z↔W, M on the ; key; Dvorak: the US positions typing Dvorak letters; Russian: Cyrillic letters by position
const azerty = { KeyQ: 'a', KeyA: 'q', KeyW: 'z', KeyZ: 'w', Semicolon: 'm', KeyM: ',' };
const dvorak = { KeyQ: "'", KeyW: ',', KeyE: '.', KeyR: 'p', KeyT: 'y', KeyY: 'f', KeyU: 'g', KeyI: 'c', KeyO: 'r', KeyP: 'l', KeyS: 'o', KeyD: 'e', KeyF: 'u', KeyG: 'i', KeyH: 'd', KeyJ: 'h', KeyK: 't', KeyL: 'n', KeyZ: ';', KeyX: 'q', KeyC: 'j', KeyV: 'k', KeyB: 'x', KeyN: 'b', KeyM: 'm' };
const russian = { KeyQ: 'й', KeyW: 'ц', KeyE: 'у', KeyR: 'к', KeyF: 'а', KeyD: 'в', KeyX: 'ч', KeyA: 'ф' };
const layout = (m) => Object.entries(m).map(([code, key]) => ({ code, key }));
const presses = [...qwerty, ...layout(azerty), ...layout(dvorak), ...layout(russian), { code: 'KeyR', key: 'Process' }, { code: 'KeyQ', key: 'Unidentified' },
{ key: 'r' }, { key: 'X' }, { code: 'KeyD' }, { key: ' ' }, { code: 'Digit1', key: '!' }, { code: 'ArrowUp', key: 'ArrowUp' }, { code: 'Enter', key: 'Enter' }, {}];
for (const e of [...presses, ...presses.map((p) => ({ ...p, key: typeof p.key === 'string' ? p.key.toUpperCase() : p.key, shiftKey: true }))]) {
assert.equal(actionForKey(e), oldShortcut(e), JSON.stringify(e));
assert.equal(actionForKey(e, DEFAULT_HOTKEYS), oldShortcut(e));
assert.equal(actionForKey(e, { ...DEFAULT_HOTKEYS }), oldShortcut(e), 'a saved copy of the defaults');
}
});
test('a rebound key: the new key acts, the old one is free; the character typed wins over the position', () => {
assert.equal(actionForKey({ key: 'w', code: 'KeyW' }, rebound), 'retreat');
assert.equal(actionForKey({ key: 'q', code: 'KeyQ' }, rebound), null);
assert.equal(actionForKey({ key: 'ц', code: 'KeyW' }, rebound), 'retreat', 'a Russian layout: by position');
assert.equal(actionForKey({ key: 'Process', code: 'KeyW' }, rebound), 'retreat', 'an IME: by position');
// AZERTY: the player bound retreat to their A key (code KeyQ) and sell to their Q key (code KeyA)
const azerty = { ...DEFAULT_HOTKEYS, retreat: 'KeyA', sell: 'KeyQ' };
assert.equal(actionForKey({ key: 'a', code: 'KeyQ' }, azerty), 'retreat');
assert.equal(actionForKey({ key: 'q', code: 'KeyA' }, azerty), 'sell');
const swapped = rebindHotkey(DEFAULT_HOTKEYS, 'refresh', 'KeyF').keys;
assert.equal(actionForKey({ key: 'f', code: 'KeyF' }, swapped), 'refresh');
assert.equal(actionForKey({ key: 'r', code: 'KeyR' }, swapped), 'freeze');
const named = { ...DEFAULT_HOTKEYS, sell: 'Delete', ready: 'KeyE', levelUp: 'Digit3', freeze: 'Comma' };
assert.equal(actionForKey({ key: 'Delete', code: 'Delete' }, named), 'sell');
assert.equal(actionForKey({ key: 'e', code: 'KeyE' }, named), 'ready');
assert.equal(actionForKey({ key: ' ', code: 'Space' }, named), null, 'Space is free once ready moved');
assert.equal(actionForKey({ key: '#', code: 'Digit3', shiftKey: true }, named), 'levelUp', 'Shift + 3: by position');
assert.equal(actionForKey({ key: ',', code: 'KeyM' }, named), 'freeze', 'AZERTY comma: by the character');
assert.equal(actionForKey({ key: 'w', code: 'KeyW' }, { retreat: 'KeyW', sell: 'KeyW' }), null, 'a corrupt map acts as the defaults');
});
test('shortcutFor with a rebound map keeps every guard: modifiers, text fields, repeats; Esc stays fixed', () => {
const w = { key: 'w', code: 'KeyW' };
assert.equal(shortcutFor(w, rebound), 'retreat');
assert.equal(shortcutFor({ key: 'q', code: 'KeyQ' }, rebound), null);
assert.equal(shortcutFor(w), null, 'W does nothing with the defaults');
for (const mod of ['ctrlKey', 'metaKey', 'altKey']) assert.equal(shortcutFor({ ...w, [mod]: true }, rebound), null, mod);
for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT', 'input']) assert.equal(shortcutFor({ ...w, target: { tagName } }, rebound), null, tagName);
assert.equal(shortcutFor({ ...w, target: { tagName: 'DIV', isContentEditable: true } }, rebound), null);
assert.equal(shortcutFor({ ...w, repeat: true }, rebound), null);
assert.equal(shortcutFor({ ...w, target: { tagName: 'BUTTON' } }, rebound), 'retreat', 'a focused HUD button does not stop it');
assert.equal(shortcutFor({ key: 'Escape' }, rebound), 'escape');
assert.equal(shortcutFor({ key: 'Escape', repeat: true }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), 'escape');
assert.equal(shortcutFor({ key: 'e', code: 'KeyE' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), 'ready', 'the ready key also pauses a solo battle (game.js)');
// behind overlays the rebound actions are blocked like the defaults
assert.equal(shortcutBlocked(shortcutFor(w, rebound), { drawer: true }), true);
assert.equal(shortcutBlocked(shortcutFor(w, rebound), { modal: true }), true);
assert.equal(shortcutBlocked(shortcutFor(w, rebound), {}), false);
});
test('the facing wheel swallows Space and every key of the map — the rebound one, not the old one', () => {
for (const [key, code] of [[' ', 'Space'], ['r', 'KeyR'], ['f', 'KeyF'], ['d', 'KeyD'], ['q', 'KeyQ'], ['x', 'KeyX']]) {
assert.equal(facingSwallows({ key, code }), true, code);
}
const g = { ...DEFAULT_HOTKEYS, refresh: 'KeyG' };
assert.equal(facingSwallows({ key: 'g', code: 'KeyG' }, g), true);
assert.equal(facingSwallows({ key: 'r', code: 'KeyR' }, g), false, 'R is free once refresh moved');
assert.equal(facingSwallows({ key: ' ', code: 'Space' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), true, 'Space always: a focused button must not activate');
assert.equal(facingSwallows({ key: 'z', code: 'KeyZ' }), false);
});
// GitHub #394 / PR #395: Enter followed the previewed direction whatever had the focus — Tab to 准备 / 设置 / ✕ and
// Enter deployed. A tiny element tree whose closest() understands the selector forms facingEnter uses.
const el = (tag, attrs = {}, parent = null) => {
const self = {
tagName: tag.toUpperCase(), attrs, parent,
matches(sel) {
return sel.split(',').map((s) => s.trim()).some((s) => {
const m = /^([a-z]*)((?:\.[\w-]+)*)((?:\[[\w-]+(?:="[^"]*")?\])*)$/.exec(s);
if (!m) return false;
if (m[1] && m[1].toUpperCase() !== self.tagName) return false;
const classes = String(attrs.class || '').split(/\s+/);
if (m[2] && !m[2].slice(1).split('.').every((c) => classes.includes(c))) return false;
for (const [, k, v] of m[3].matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
if (!(k in attrs) || (v !== undefined && String(attrs[k]) !== v)) return false;
}
return !!(m[1] || m[2] || m[3]);
});
},
closest(sel) { for (let n = self; n; n = n.parent) if (n.matches(sel)) return n; return null; },
};
return self;
};
test('the facing wheel\'s Enter follows the focus: ✕ cancels, another control does nothing, elsewhere it commits', () => {
const body = el('body');
const wheel = el('div', { class: 'fwheel', role: 'dialog', tabindex: '-1' }, body);
const cancel = el('button', { class: 'fwheel__cancel' }, el('div', { class: 'fwheel__dia' }, wheel));
const cancelText = el('span', {}, cancel);
const ready = el('button', { class: 'ready' }, el('header', {}, body));
const gear = el('button', { class: 'gm__gear' }, body);
const card = el('div', { role: 'button', tabindex: '0' }, body);
const link = el('a', { href: '#' }, body);
const field = el('input', { type: 'text' }, body);
for (const chosen of [true, false]) {
assert.equal(facingEnter({ key: 'Enter', target: cancel }, chosen), 'cancel', 'Enter on ✕ cancels (a direction previewed or not)');
assert.equal(facingEnter({ key: 'Enter', target: cancelText }, chosen), 'cancel');
for (const t of [ready, gear, card, link, field]) assert.equal(facingEnter({ key: 'Enter', target: t }, chosen), 'swallow', 'another control does nothing');
}
assert.equal(facingEnter({ key: 'Enter', target: wheel }, true), 'commit', 'the wheel has the focus when it opens: Enter confirms');
assert.equal(facingEnter({ key: 'Enter', target: body }, true), 'commit');
assert.equal(facingEnter({ key: 'Enter', target: null }, true), 'commit');
assert.equal(facingEnter({ key: 'Enter', target: wheel }, false), 'swallow', 'nothing to confirm yet');
assert.equal(facingEnter({ key: ' ', target: cancel }, true), null, 'not Enter: Space stays with facingSwallows');
assert.equal(facingEnter({ key: 'ArrowUp', target: body }, true), null);
assert.equal(facingEnter(null, true), null);
});
test('the wheel wires it: Enter through facingEnter, the wheel focusable and focused when it opens', () => {
const src = read('public/js/ui/facingWheel.js');
assert.match(src, /const enter = facingEnter\(e, !!L\.dir\);/);
assert.doesNotMatch(src, /e\.key === 'Enter' && L\.dir/, 'no Enter that ignores the focus');
assert.match(src, /<div ref=\$\{rootRef\} tabIndex="-1" class=\$\{cx\('fwheel'/);
assert.match(src, /rootRef\.current\?\.focus\(\{ preventScroll: true \}\)/);
});
});
describe('capturing a new key (the settings wait for a key)', () => {
test('Esc cancels, Tab leaves, a lone modifier or a repeat is ignored', () => {
assert.deepEqual(captureHotkey({ key: 'Escape', code: 'Escape' }), { kind: 'cancel' });
assert.deepEqual(captureHotkey({ key: 'Tab', code: 'Tab', shiftKey: true }), { kind: 'leave' });
for (const key of ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'AltGraph']) assert.deepEqual(captureHotkey({ key, code: `${key}Left` }), { kind: 'ignore' }, key);
assert.deepEqual(captureHotkey({ key: 'w', code: 'KeyW', repeat: true }), { kind: 'ignore' });
assert.deepEqual(captureHotkey(null), { kind: 'ignore' });
});
test('a key held with Ctrl / Alt / ⌘ and the keys the interface keeps are refused, with the key named', () => {
assert.deepEqual(captureHotkey({ key: 'k', code: 'KeyK', ctrlKey: true }), { kind: 'refuse', reason: 'modifier', name: 'K' });
assert.deepEqual(captureHotkey({ key: '@', code: 'KeyQ', ctrlKey: true, altKey: true }), { kind: 'refuse', reason: 'modifier', name: '@' });
assert.deepEqual(captureHotkey({ key: 'w', code: 'KeyW', metaKey: true }), { kind: 'refuse', reason: 'modifier', name: 'W' });
for (const [key, code] of [['Enter', 'Enter'], ['ArrowUp', 'ArrowUp'], ['F5', 'F5'], ['Backspace', 'Backspace'], ['ContextMenu', 'ContextMenu']]) {
assert.deepEqual(captureHotkey({ key, code }), { kind: 'refuse', reason: 'reserved', name: key }, key);
}
});
test('the new key: the character typed (the keycap), else the physical key', () => {
assert.deepEqual(captureHotkey({ key: 'w', code: 'KeyW' }), { kind: 'key', code: 'KeyW' });
assert.deepEqual(captureHotkey({ key: 'W', code: 'KeyW', shiftKey: true }), { kind: 'key', code: 'KeyW' });
assert.deepEqual(captureHotkey({ key: ' ', code: 'Space' }), { kind: 'key', code: 'Space' });
assert.deepEqual(captureHotkey({ key: 'Delete', code: 'Delete' }), { kind: 'key', code: 'Delete' });
assert.deepEqual(captureHotkey({ key: '1', code: 'Numpad1' }), { kind: 'key', code: 'Digit1' }, 'the numpad 1 types 1');
assert.equal(hotkeyOf({ key: 'a', code: 'KeyQ' }), 'KeyA', 'AZERTY: the A keycap');
assert.equal(hotkeyOf({ key: ',', code: 'KeyM' }), 'Comma', 'AZERTY: the comma keycap');
assert.equal(hotkeyOf({ key: 'й', code: 'KeyQ' }), 'KeyQ', 'Russian: by position');
assert.equal(hotkeyOf({ key: 'Process', code: 'KeyR' }), 'KeyR', 'an IME: by position');
assert.equal(hotkeyOf({ key: '!', code: 'Digit1', shiftKey: true }), 'Digit1', 'Shift + 1: by position');
assert.equal(hotkeyOf({ key: 'Enter', code: 'Enter' }), null);
assert.equal(hotkeyOf({}), null);
});
});
describe('every handler and key hint reads the map', () => {
const files = [];
const visit = (rel) => {
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
const r = `${rel}/${e.name}`;
if (e.isDirectory()) visit(r);
else if (r.endsWith('.js')) files.push(r);
}
};
visit('public/js');
test('game.js and the facing wheel pass the player\'s map to the one lookup; no other file names a rebindable key', () => {
assert.match(read('public/js/screens/game.js'), /shortcutFor\(e, settingsStore\.get\(\)\.keys\)/);
assert.match(read('public/js/ui/facingWheel.js'), /facingSwallows\(e, settingsStore\.get\(\)\.keys\)/);
for (const f of files) {
if (f === 'public/js/ui/gameLogic/shortcuts.js') continue;
const src = read(f);
assert.doesNotMatch(src, /['"`]Key[RFDQX]['"`]|\^Key\[|code === 'Space'/, `${f}: a rebindable key hard-coded`);
assert.doesNotMatch(src, /<kbd[^>]*>(R|F|D|Q|X|Space)<\/kbd>/, `${f}: a fixed key hint`);
assert.doesNotMatch(src, /[((]Space[))]|[((][QX][))]|\[[QX]\]|· [RFD]'/, `${f}: a fixed key in a hint text`);
}
});
test('the HUD hints follow the settings: hotkeyLabelOf and the ready button after a rebind', async () => {
const { settingsStore, updateSettings, hotkeyLabelOf } = await import('../../public/js/ui/settings.js');
const { ReadyToggle } = await import('../../public/js/ui/hud.js');
after(() => updateSettings({ keys: DEFAULT_HOTKEYS }));
const kbdOf = (v) => {
const stack = [v];
while (stack.length) {
const n = stack.pop();
if (Array.isArray(n)) { stack.push(...n); continue; }
if (!n || typeof n !== 'object') continue;
if (n.type === 'kbd') return [n.props.children].flat().join('');
stack.push(n.props?.children);
}
return null;
};
const priv = { ready: false, alive: true, temp: [], funds: 0 };
assert.deepEqual(HOTKEY_ACTIONS.map(hotkeyLabelOf), ['R', 'F', 'D', 'Q', 'X', 'Space']);
assert.equal(kbdOf(ReadyToggle({ priv, onToggle() {} })), 'Space');
updateSettings({ keys: rebindHotkey(settingsStore.get().keys, 'ready', 'KeyE').keys });
updateSettings({ keys: rebindHotkey(settingsStore.get().keys, 'retreat', 'KeyW').keys });
assert.deepEqual(settingsStore.get().keys, { ...DEFAULT_HOTKEYS, ready: 'KeyE', retreat: 'KeyW' });
assert.equal(hotkeyLabelOf('retreat'), 'W');
assert.equal(kbdOf(ReadyToggle({ priv, onToggle() {} })), 'E');
updateSettings({ keys: { retreat: 'Escape' } });
assert.equal(hotkeyLabelOf('retreat'), 'Q', 'a bad patch falls back to the defaults');
updateSettings({ keys: DEFAULT_HOTKEYS });
assert.equal(kbdOf(ReadyToggle({ priv, onToggle() {} })), 'Space');
});
});