explodorama / files / test / keyCursor.test.ts
keyCursor.test.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
720cc9d3 2026-11-03 Pavel Safronov | 2 | import { defaultButton, escStep, keyButton, keyDirection, keyRole, modeAfterKey, ignoredForMode, panStep, targetTakesKey, type KeyInfo } from '../src/ui/keyCursor' |
92cc4ee3 2026-11-02 Pavel Safronov | 3 | import { cursorWanted, tileActions } from '../src/ui/padTiles' |
| 4 | import { board, v } from './helpers' | |
| 5 | ||
| 6 | const key = (code: string, mods: Partial<KeyInfo> = {}): KeyInfo => ({ code, key: mods.key ?? code, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, ...mods }) | |
| 7 | ||
| 8 | const room = () => | |
| 9 | board(` | |
| 10 | ####### | |
| 11 | #.....# | |
| 12 | #.....# | |
| 13 | #..P..# | |
| 14 | #.....# | |
| 15 | #.....# | |
| 16 | #######`) | |
| 17 | ||
| 18 | describe('keyRole', () => { | |
| 19 | it('Shift + an arrow or W A S D hops; Alt pans; plain is a direction', () => { | |
| 20 | expect(keyRole(key('ArrowLeft', { shiftKey: true }))).toBe('hop') | |
| 21 | expect(keyRole(key('KeyW', { shiftKey: true }))).toBe('hop') | |
| 22 | expect(keyRole(key('ArrowLeft', { altKey: true }))).toBe('pan') | |
| 23 | expect(keyRole(key('ArrowLeft'))).toBe('direction') | |
| 24 | expect(keyRole(key('KeyQ'))).toBe('diagonal') | |
| 25 | expect(keyRole(key('Home'))).toBe('recenter') | |
| 26 | expect(keyRole(key('Slash', { key: '?', shiftKey: true }))).toBe('inspect') | |
| 27 | expect(keyRole(key('ShiftLeft', { shiftKey: true }))).toBe('modifier') | |
| 28 | expect(keyRole(key('KeyM'))).toBe('other') | |
| 29 | }) | |
| 30 | it('Ctrl and Meta combinations are the browser\'s', () => { | |
| 31 | expect(keyRole(key('ArrowLeft', { ctrlKey: true }))).toBe('other') | |
| 32 | expect(keyRole(key('ArrowLeft', { metaKey: true, shiftKey: true }))).toBe('other') | |
| 33 | }) | |
| 34 | it('Ctrl and Meta with ?, / and Home do nothing for the cursor', () => { | |
| 35 | for (const mods of [{ ctrlKey: true }, { metaKey: true }, { metaKey: true, shiftKey: true }]) { | |
| 36 | expect(keyRole(key('Slash', { key: '/', ...mods }))).toBe('other') | |
| 37 | expect(keyRole(key('Slash', { key: '?', ...mods }))).toBe('other') | |
| 38 | expect(keyRole(key('Home', mods))).toBe('other') | |
| 39 | } | |
| 40 | }) | |
| 41 | it('? is matched by what it types, not by the Slash key (a German layout types - there)', () => { | |
| 42 | expect(keyRole(key('Slash', { key: '-' }))).toBe('other') | |
| 43 | expect(keyRole(key('Minus', { key: '?', shiftKey: true }))).toBe('inspect') | |
| 44 | }) | |
| 45 | it('directions point as the board does (y down)', () => { | |
| 46 | expect(keyDirection('ArrowUp')).toEqual({ dx: 0, dy: -1 }) | |
| 47 | expect(keyDirection('KeyD')).toEqual({ dx: 1, dy: 0 }) | |
| 48 | expect(keyDirection('KeyM')).toBeNull() | |
| 49 | }) | |
| 50 | }) | |
| 51 | ||
| 52 | describe('modeAfterKey', () => { | |
| 53 | it('while a dialog is open or the demo plays (locked), no key wakes the keyboard, but a pad hands back', () => { | |
| 54 | expect(modeAfterKey(null, key('ArrowUp', { shiftKey: true }), false, true)).toBeNull() | |
| 55 | expect(modeAfterKey(null, key('Slash', { key: '?' }), false, true)).toBeNull() | |
| 56 | expect(modeAfterKey('pad', key('ArrowUp', { shiftKey: true }), false, true)).toBeNull() | |
| 57 | expect(modeAfterKey('pad', key('KeyM'), false, true)).toBeNull() | |
| 58 | expect(modeAfterKey('keys', key('KeyM'), false, true)).toBe('keys') | |
| 59 | }) | |
| 60 | it('Shift + arrow or ? wakes the keyboard, whoever had the board', () => { | |
| 61 | for (const mode of [null, 'pad', 'keys'] as const) { | |
| 62 | expect(modeAfterKey(mode, key('ArrowUp', { shiftKey: true }), false)).toBe('keys') | |
| 63 | expect(modeAfterKey(mode, key('Slash', { key: '?' }), false)).toBe('keys') | |
| 64 | } | |
| 65 | }) | |
| 66 | it('the cursor\'s own keys keep the keyboard\'s mode', () => { | |
| 67 | for (const code of ['ShiftLeft', 'AltLeft', 'Home', 'KeyM', 'KeyB', 'Space', 'Enter', 'Escape', 'Tab']) expect(modeAfterKey('keys', key(code), false)).toBe('keys') | |
| 68 | expect(modeAfterKey('keys', key('ArrowLeft', { altKey: true }), false)).toBe('keys') | |
| 69 | }) | |
| 70 | it('a plain arrow or diagonal step hands the board back to the plain keys', () => { | |
| 71 | expect(modeAfterKey('keys', key('ArrowLeft'), false)).toBeNull() | |
| 72 | expect(modeAfterKey('keys', key('KeyD'), false)).toBeNull() | |
| 73 | expect(modeAfterKey('keys', key('KeyQ'), false)).toBeNull() | |
| 74 | }) | |
| 75 | it('while "What is that?" is on the plain arrows aim, whoever had the board', () => { | |
| 76 | for (const mode of [null, 'pad', 'keys'] as const) expect(modeAfterKey(mode, key('ArrowLeft'), true)).toBe('keys') | |
| 77 | }) | |
| 78 | it('a controller\'s board is handed back by every other key, as before', () => { | |
| 79 | for (const code of ['KeyM', 'ShiftLeft', 'Escape', 'ArrowLeft', 'Home']) expect(modeAfterKey('pad', key(code), false)).toBeNull() | |
| 80 | }) | |
| 81 | it('with nobody\'s cursor, other keys keep it so', () => { | |
| 82 | for (const code of ['KeyM', 'Enter', 'ShiftLeft', 'ArrowLeft']) expect(modeAfterKey(null, key(code), false)).toBeNull() | |
| 83 | }) | |
| 84 | }) | |
| 85 | ||
| 86 | describe('the cursor shows for either input', () => { | |
| 87 | const ok = { padActive: true, demo: false, dialogOpen: false, inspecting: false, offerPending: false, autopilot: false, status: 'playing' as const } | |
| 88 | it('cursorWanted takes "someone has the board" (main.ts passes inputMode !== null)', () => { | |
| 89 | expect(cursorWanted(ok)).toBe(true) | |
| 90 | expect(cursorWanted({ ...ok, padActive: false })).toBe(false) | |
| 91 | }) | |
| 92 | }) | |
| 93 | ||
| 94 | describe('panStep', () => { | |
| 95 | it('Up moves the view up (y positive), Left left', () => { | |
| 96 | expect(panStep('ArrowUp')).toEqual({ x: 0, y: 0.5 }) | |
| 97 | expect(panStep('ArrowLeft')).toEqual({ x: -0.5, y: -0 }) | |
| 98 | expect(panStep('KeyM')).toBeNull() | |
| 99 | }) | |
| 100 | }) | |
| 101 | ||
| 102 | describe('targetTakesKey (Enter and Space on a focused control)', () => { | |
| 103 | it('buttons, links, inputs, selects take it', () => { | |
| 104 | for (const tagName of ['BUTTON', 'A', 'INPUT', 'TEXTAREA', 'SELECT', 'button']) expect(targetTakesKey({ tagName })).toBe(true) | |
| 105 | }) | |
| 106 | it('the page, the canvas and a panel do not', () => { | |
| 107 | for (const tagName of ['BODY', 'CANVAS', 'DIV', 'HTML']) expect(targetTakesKey({ tagName })).toBe(false) | |
| 108 | expect(targetTakesKey(null)).toBe(false) | |
| 109 | }) | |
| 110 | it('editable content does', () => { | |
| 111 | expect(targetTakesKey({ tagName: 'DIV', isContentEditable: true })).toBe(true) | |
| 112 | }) | |
| 113 | }) | |
| 114 | ||
| 115 | describe('keyButton', () => { | |
| 116 | it('M, B, V, Space name their own button when it is possible on the tile', () => { | |
| 117 | const s = room() | |
| 118 | const next = tileActions(s, v(4, 3)) | |
| 119 | expect(keyButton(next, 'move')).toBe('move') | |
| 120 | expect(keyButton(next, 'bomb')).toBe('bomb') | |
| 121 | expect(keyButton(next, 'wait')).toBeNull() | |
| 122 | expect(keyButton(tileActions(s, s.player), 'wait')).toBe('wait') | |
| 123 | }) | |
| 124 | it('a tile that offers nothing for the key gives null (the key then picks the action)', () => { | |
| 125 | const s = room() | |
| 126 | expect(keyButton(tileActions(s, v(1, 1)), 'move')).toBeNull() | |
| 127 | expect(keyButton(tileActions(s, v(4, 3)), 'kick')).toBeNull() | |
| 128 | }) | |
| 129 | }) | |
| 130 | ||
| 131 | describe('defaultButton (Enter on the cursor)', () => { | |
| 132 | it('a tile next to the player: its first action, Move', () => { | |
| 133 | const s = room() | |
| 134 | expect(defaultButton(tileActions(s, v(4, 3)), v(4, 3), s.player)).toBe('move') | |
| 135 | }) | |
| 136 | it('the player\'s own tile: nothing, as a tap there does nothing', () => { | |
| 137 | const s = room() | |
| 138 | expect(defaultButton(tileActions(s, s.player), s.player, s.player)).toBeNull() | |
| 139 | }) | |
| 140 | it('a tile nothing can use: nothing', () => { | |
| 141 | const s = room() | |
| 142 | expect(defaultButton(tileActions(s, v(1, 1)), v(1, 1), s.player)).toBeNull() | |
| 143 | }) | |
| 144 | }) | |
| 145 | ||
| 146 | describe('escStep', () => { | |
| 147 | it('cancels what is open first, then brings the cursor home, then puts it away', () => { | |
| 148 | expect(escStep({ inspecting: true, picked: false, onPlayer: false })).toBe('cancel') | |
| 149 | expect(escStep({ inspecting: false, picked: true, onPlayer: false })).toBe('cancel') | |
| 150 | expect(escStep({ inspecting: false, picked: false, onPlayer: false })).toBe('home') | |
| 151 | expect(escStep({ inspecting: false, picked: false, onPlayer: true })).toBe('hide') | |
| 152 | }) | |
| 153 | }) | |
720cc9d3 2026-11-03 Pavel Safronov | 154 | |
| 155 | describe('ignoredForMode (Q81: a screenshot must not take the board from the pad)', () => { | |
| 156 | it('a modifier alone and any Cmd combination are ignored; plain play keys are not', () => { | |
| 157 | expect(ignoredForMode(key('MetaLeft', { key: 'Meta', metaKey: true }))).toBe(true) | |
| 158 | expect(ignoredForMode(key('ShiftLeft', { key: 'Shift', shiftKey: true }))).toBe(true) | |
| 159 | expect(ignoredForMode(key('Digit4', { key: '4', metaKey: true, shiftKey: true }))).toBe(true) | |
| 160 | expect(ignoredForMode(key('ArrowUp', { key: 'ArrowUp', shiftKey: true }))).toBe(false) | |
| 161 | expect(ignoredForMode(key('KeyM', { key: 'm' }))).toBe(false) | |
| 162 | }) | |
| 163 | }) |