fuzzyworld

explodorama / files / test / gamepad.test.ts

gamepad.test.ts

Blame: the commit that last changed each line · file · history

ebcd0a8f 2026-11-02 Pavel Safronov1import { describe, expect, it } from 'vitest'
92cc4ee3 2026-11-02 Pavel Safronov2import { HOLD_SECONDS } from '../src/ui/padHold'
ebcd0a8f 2026-11-02 Pavel Safronov3import { createPadInput, type PadLike, type PadTargets } from '../src/ui/gamepad'
4
5function pad(over: Partial<PadLike> = {}, pressed: number[] = []): PadLike {
6 return {
7 index: 0,
8 id: 'Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)',
9 connected: true,
10 mapping: 'standard',
11 buttons: Array.from({ length: 17 }, (_, i) => ({ pressed: pressed.includes(i) })),
12 axes: [0, 0, 0, 0],
13 ...over,
14 }
15}
16
17function setup() {
18 const calls: string[] = []
19 const targets: PadTargets = {
20 dir: (dx, dy) => calls.push(`dir ${dx},${dy}`),
21 press: (b) => calls.push(`press ${b}`),
22 look: (dx, dy) => calls.push(`look ${dx.toFixed(2)},${dy.toFixed(2)}`),
23 lookEnd: () => calls.push('lookEnd'),
24 active: () => calls.push('active'),
25 toast: (title, text) => calls.push(`toast ${title} | ${text}`),
26 }
27 let pads: (PadLike | null)[] = []
28 let focused = true
29 const input = createPadInput(targets, () => pads, () => focused)
30 return { calls, input, set: (...p: (PadLike | null)[]) => (pads = p), focus: (on: boolean) => (focused = on), targets }
31}
32
33describe('the pad input', () => {
34 it('says nothing and calls nothing with no pad', () => {
35 const t = setup()
36 t.set(null)
37 t.input.poll(0)
38 expect(t.calls).toEqual([])
39 })
40
41 it('announces a standard pad once, with its family', () => {
42 const t = setup()
43 t.set(pad())
44 t.input.poll(0)
45 t.input.poll(16)
46 expect(t.calls.filter((c) => c.startsWith('toast'))).toEqual(['toast Controller connected | PlayStation'])
47 })
48
49 it('routes a press, a direction and the right stick to the targets', () => {
50 const t = setup()
51 t.set(pad())
52 t.input.poll(0)
53 t.calls.length = 0
54 t.set(pad({}, [0, 15]))
55 t.input.poll(16)
92cc4ee3 2026-11-02 Pavel Safronov56 // The d-pad acts at once; the face button only once it has been held until the ring is full (padHold.ts).
57 expect(t.calls).not.toContain('press move')
ebcd0a8f 2026-11-02 Pavel Safronov58 expect(t.calls).toContain('dir 1,0')
92cc4ee3 2026-11-02 Pavel Safronov59 t.set(pad({}, [0]))
60 t.input.poll(16 + HOLD_SECONDS * 1000)
61 t.set(pad())
62 t.input.poll(32 + HOLD_SECONDS * 1000)
63 expect(t.calls).toContain('press move')
ebcd0a8f 2026-11-02 Pavel Safronov64 t.calls.length = 0
65 t.set(pad({ axes: [0, 0, 1, 0] }))
92cc4ee3 2026-11-02 Pavel Safronov66 t.input.poll(1000)
ebcd0a8f 2026-11-02 Pavel Safronov67 expect(t.calls).toContain('look 1.00,0.00')
68 t.set(pad())
92cc4ee3 2026-11-02 Pavel Safronov69 t.input.poll(1016)
ebcd0a8f 2026-11-02 Pavel Safronov70 expect(t.calls).toContain('lookEnd')
71 })
72
73 it('marks the pad active only when something was pressed', () => {
74 const t = setup()
75 t.set(pad())
76 t.input.poll(0)
77 t.calls.length = 0
78 t.input.poll(16)
79 expect(t.calls).toEqual([])
80 t.set(pad({}, [1]))
81 t.input.poll(32)
82 expect(t.calls[0]).toBe('active')
83 })
84
85 it('refuses a non-standard pad with one toast and never calls the targets', () => {
86 const t = setup()
87 const odd = pad({ mapping: '', id: 'Mystery' }, [0, 15])
88 t.set(odd)
89 t.input.poll(0)
90 t.input.poll(16)
91 t.input.poll(32)
92 expect(t.calls).toEqual(['toast Controller not recognised | (non-standard layout)'])
93 })
94
95 it('starts a swapped pad fresh: a button the old one held is not a new press', () => {
96 const t = setup()
97 t.set(pad({}, [0]))
98 t.input.poll(0)
99 t.input.poll(16)
100 t.calls.length = 0
101 // A different pad appears with the same button down: it is only woken, not pressed.
102 t.set(pad({ index: 1, id: 'Xbox Wireless Controller (STANDARD GAMEPAD)' }, [0]))
103 t.input.poll(32)
104 expect(t.calls).toEqual(['active', 'toast Controller connected | Xbox'])
105 t.input.poll(48)
106 expect(t.calls.filter((c) => c.startsWith('press'))).toEqual([])
107 })
108
109 it('forgets a pad that is unplugged, and announces it again when it returns', () => {
110 const t = setup()
111 t.set(pad())
112 t.input.poll(0)
113 t.set(null)
114 t.input.poll(16)
115 t.set(pad())
116 t.input.poll(32)
117 expect(t.calls.filter((c) => c.startsWith('toast'))).toHaveLength(2)
118 })
119
120 it('uses the first connected pad, skipping empty and disconnected slots', () => {
121 const t = setup()
122 t.set(null, pad({ index: 1, connected: false }), pad({ index: 2 }))
123 t.input.poll(0)
124 expect(t.calls).toContain('toast Controller connected | PlayStation')
125 })
126
127 it('does nothing while the window is not focused, and a button held across is not a press', () => {
128 const t = setup()
129 t.set(pad())
130 t.input.poll(0)
131 t.calls.length = 0
132 t.focus(false)
133 t.set(pad({}, [0]))
134 t.input.poll(16)
135 expect(t.calls).toEqual([])
136 t.focus(true)
137 t.input.poll(32)
138 t.input.poll(48)
139 expect(t.calls.filter((c) => c.startsWith('press'))).toEqual([])
140 })
141
142 it('ends a look when the pad is unplugged, swapped or the window loses focus', () => {
143 for (const how of ['unplug', 'swap', 'blur'] as const) {
144 const t = setup()
145 t.set(pad())
146 t.input.poll(0)
147 t.set(pad({ axes: [0, 0, 1, 0] }))
148 t.input.poll(16)
149 t.calls.length = 0
150 if (how === 'unplug') t.set(null)
151 else if (how === 'swap') t.set(pad({ index: 1, id: 'Other (STANDARD GAMEPAD)' }))
152 else t.focus(false)
153 t.input.poll(32)
154 expect(t.calls).toContain('lookEnd')
155 }
156 })
157
158 it('prefers a standard pad over an earlier non-standard one', () => {
159 const t = setup()
160 t.set(pad({ index: 0, mapping: '', id: 'Mystery' }), pad({ index: 1 }))
161 t.input.poll(0)
162 expect(t.calls).toEqual(['active', 'toast Controller connected | PlayStation'])
163 })
164
165 it('falls back to a non-standard pad only to say it is not recognised', () => {
166 const t = setup()
167 t.set(null, pad({ mapping: '' }, [0]))
168 t.input.poll(0)
169 expect(t.calls).toEqual(['toast Controller not recognised | (non-standard layout)'])
170 })
171
172 it('stops polling, once, when reading the pads throws', () => {
173 const errors: unknown[] = []
174 let reads = 0
175 const input = createPadInput(
176 { dir() {}, press() {}, look() {}, lookEnd() {}, active() {}, toast() {}, error: (e) => errors.push(e) },
177 () => {
178 reads++
179 throw new Error('boom')
180 },
181 () => true,
182 )
183 expect(() => input.poll(0)).not.toThrow()
184 input.poll(16)
185 input.poll(32)
186 expect(errors).toHaveLength(1)
187 expect(reads).toBe(1)
188 })
189})
190
191describe('face buttons', () => {
192 it('carry their board meaning, and the raw index for dialogs', () => {
193 const t = setup()
194 const faces: { name: string; face?: number }[] = []
195 const input = createPadInput(
196 { ...t.targets, press: (name, face) => faces.push({ name, face }) },
197 () => [pad({}, [])],
198 () => true,
199 )
200 input.poll(0)
92cc4ee3 2026-11-02 Pavel Safronov201 // Each is held for the full time (it acts then), then let go.
202 const full = HOLD_SECONDS * 1000
203 let frame = pad()
204 const input2 = createPadInput({ ...t.targets, press: (name, face) => faces.push({ name, face }) }, () => [frame], () => true)
ebcd0a8f 2026-11-02 Pavel Safronov205 input2.poll(0)
92cc4ee3 2026-11-02 Pavel Safronov206 for (const [k, down] of [0, 3].entries()) {
207 const t0 = 100 + k * 3000
208 frame = pad({}, [down])
209 input2.poll(t0)
210 input2.poll(t0 + full)
211 frame = pad()
212 input2.poll(t0 + full + 16)
213 }
ebcd0a8f 2026-11-02 Pavel Safronov214 expect(faces).toEqual([{ name: 'move', face: 0 }, { name: 'charge', face: 3 }])
215 })
216})
217
92cc4ee3 2026-11-02 Pavel Safronov218describe('a Nintendo pad', () => {
219 const SWITCH = 'Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)'
220 it('A (the right button, index 1) is move; B (bottom, index 0) is wait; the raw index goes along', () => {
221 const t = setup()
222 const presses: { name: string; face?: number }[] = []
223 let frame = pad({ id: SWITCH })
224 const input = createPadInput({ ...t.targets, press: (name, face) => presses.push({ name, face }) }, () => [frame], () => true)
225 input.poll(0)
226 const full = HOLD_SECONDS * 1000
227 for (const [k, down] of [1, 0].entries()) {
228 const t0 = 100 + k * 3000
229 frame = pad({ id: SWITCH }, [down])
230 input.poll(t0)
231 input.poll(t0 + full)
232 frame = pad({ id: SWITCH })
233 input.poll(t0 + full + 16)
234 }
235 expect(presses).toEqual([{ name: 'move', face: 1 }, { name: 'wait', face: 0 }])
236 })
237})
238
ebcd0a8f 2026-11-02 Pavel Safronov239describe('the pad family', () => {
240 it('is passed on when a standard pad connects', () => {
241 const families: string[] = []
242 const t = setup()
243 const input = createPadInput({ ...t.targets, family: (f) => families.push(f) }, () => [pad()], () => true)
244 input.poll(0)
245 input.poll(16)
246 expect(families).toEqual(['playstation'])
247 })
248})
92cc4ee3 2026-11-02 Pavel Safronov249
250describe('hold to confirm', () => {
251 /** A setup that records the ring too. */
252 function held() {
253 const t = setup()
254 const log: string[] = []
255 const targets: PadTargets = {
256 ...t.targets,
257 hold: (face, p) => log.push(`hold ${face} ${p.toFixed(2)}`),
258 holdEnd: () => log.push('end'),
259 press: (b, face) => log.push(`press ${b} ${face}`),
260 }
261 let frame = pad()
262 const input = createPadInput(targets, () => [frame], () => true)
263 input.poll(0)
264 return { log, input, show: (...down: number[]) => (frame = pad({}, down)), t }
265 }
266
267 it('reports the ring filling and acts the moment it is full, while still held; release only clears the ring', () => {
268 const h = held()
269 h.show(0)
270 h.input.poll(100)
271 h.input.poll(100 + HOLD_SECONDS * 500)
272 h.input.poll(100 + HOLD_SECONDS * 1000)
273 expect(h.log).toEqual(['hold 0 0.00', 'hold 0 0.50', 'hold 0 1.00', 'press move 0'])
274 h.input.poll(100 + HOLD_SECONDS * 1000 + 16)
275 h.input.poll(100 + HOLD_SECONDS * 3000)
276 expect(h.log).toHaveLength(4)
277 h.show()
278 h.input.poll(100 + HOLD_SECONDS * 3000 + 16)
279 expect(h.log).toEqual(['hold 0 0.00', 'hold 0 0.50', 'hold 0 1.00', 'press move 0', 'end'])
280 })
281
282 it('does not fire again, nor start a new hold, when the place changes under a held used-up button', () => {
283 const t = setup()
284 const log: string[] = []
285 let where = 'demo'
286 let frame = pad()
287 const input = createPadInput(
288 { ...t.targets, hold: (f, p) => log.push(`hold ${f} ${p.toFixed(2)}`), holdEnd: () => log.push('end'), press: (b, f) => { log.push(`press ${b} ${f}`); where = 'playing' } },
289 () => [frame],
290 () => true,
291 () => where,
292 )
293 input.poll(0)
294 frame = pad({}, [0])
295 input.poll(100)
296 input.poll(100 + HOLD_SECONDS * 1000)
297 // The action changed the place: the full ring goes at once, the button stays used up.
298 expect(log).toEqual(['hold 0 0.00', 'hold 0 1.00', 'press move 0', 'end'])
299 input.poll(100 + HOLD_SECONDS * 1000 + 500)
300 input.poll(100 + HOLD_SECONDS * 3000)
301 expect(log).toHaveLength(4)
302 // Let go, then press again: a fresh hold.
303 frame = pad()
304 input.poll(100 + HOLD_SECONDS * 3000 + 16)
305 frame = pad({}, [0])
306 input.poll(100 + HOLD_SECONDS * 3000 + 32)
307 expect(log[4]).toBe('hold 0 0.00')
308 expect(log).toHaveLength(5)
309 })
310
311 it('keeps a used-up button used up when an instant button is pressed, and ignores a second face button', () => {
312 const h = held()
313 h.show(0)
314 h.input.poll(100)
315 h.input.poll(100 + HOLD_SECONDS * 1000)
316 h.show(0, 1)
317 h.input.poll(100 + HOLD_SECONDS * 1000 + 16)
318 h.show(0, 1, 9)
319 h.input.poll(100 + HOLD_SECONDS * 1000 + 32)
320 h.show(0)
321 h.input.poll(100 + HOLD_SECONDS * 3000)
322 expect(h.log).toEqual(['hold 0 0.00', 'hold 0 1.00', 'press move 0', 'press menu undefined'])
323 h.show()
324 h.input.poll(100 + HOLD_SECONDS * 3000 + 16)
325 expect(h.log.at(-1)).toBe('end')
326 })
327
328 it('a used-up button is free again after unplug, blur or a swapped pad', () => {
329 for (const how of ['unplug', 'blur', 'swap']) {
330 const t = setup()
331 const log: string[] = []
332 let focused = true
333 let frame: PadLike | null = pad()
334 const input = createPadInput({ ...t.targets, holdEnd: () => log.push('end'), press: () => log.push('press') }, () => [frame], () => focused)
335 input.poll(0)
336 frame = pad({}, [0])
337 input.poll(100)
338 input.poll(100 + HOLD_SECONDS * 1000)
339 if (how === 'unplug') frame = null
340 else if (how === 'blur') focused = false
341 else frame = pad({ index: 1, id: 'Other' }, [0])
342 input.poll(100 + HOLD_SECONDS * 1000 + 16)
343 expect(log, how).toEqual(['press', 'end'])
344 focused = true
345 frame = pad()
346 input.poll(100 + HOLD_SECONDS * 1000 + 32)
347 frame = pad({}, [0])
348 input.poll(100 + HOLD_SECONDS * 1000 + 48)
349 input.poll(100 + HOLD_SECONDS * 2000 + 48)
350 expect(log, how).toEqual(['press', 'end', 'press'])
351 }
352 })
353
354 it('does nothing when released early', () => {
355 const h = held()
356 h.show(2)
357 h.input.poll(100)
358 h.input.poll(300)
359 h.show()
360 h.input.poll(316)
361 h.input.poll(5000)
362 expect(h.log).toEqual(['hold 2 0.00', 'hold 2 0.40', 'end'])
363 })
364
365 it('tells a button let go too early (tap), but not a completed hold or a cancelled one', () => {
366 const t = setup()
367 const log: string[] = []
368 let frame = pad()
369 const input = createPadInput({ ...t.targets, tap: (f) => log.push(`tap ${f}`), press: () => log.push('press') }, () => [frame], () => true)
370 input.poll(0)
371 frame = pad({}, [0])
372 input.poll(100)
373 frame = pad()
374 input.poll(300)
375 expect(log).toEqual(['tap 0'])
376 frame = pad({}, [0])
377 input.poll(400)
378 input.poll(400 + HOLD_SECONDS * 1000)
379 frame = pad()
380 input.poll(2000)
381 expect(log).toEqual(['tap 0', 'press'])
382 })
383
384 it('acts at once for the buttons that are not face buttons', () => {
385 const h = held()
386 h.show(5)
387 h.input.poll(100)
388 expect(h.log).toEqual(['press info undefined'])
389 })
390
720cc9d3 2026-11-03 Pavel Safronov391 it('Q88: a face button acts at once, with no ring, where `instant` says so', () => {
392 const t = setup()
393 let instant = true
394 t.targets.instant = () => instant
395 t.set(pad())
396 t.input.poll(0)
397 t.calls.length = 0
398 t.set(pad({}, [0]))
399 t.input.poll(16)
400 expect(t.calls).toContain('press move')
401 // Held on, it does not act again; let go and pressed again, it does.
402 t.calls.length = 0
403 t.input.poll(32)
404 expect(t.calls).not.toContain('press move')
405 t.set(pad())
406 t.input.poll(48)
407 instant = false
408 t.set(pad({}, [0]))
409 t.input.poll(64)
410 expect(t.calls).not.toContain('press move')
411 })
412
92cc4ee3 2026-11-02 Pavel Safronov413 it('holds one button at a time', () => {
414 const h = held()
415 h.show(0)
416 h.input.poll(100)
417 h.show(0, 1)
418 h.input.poll(200)
419 h.show(0)
420 h.input.poll(100 + HOLD_SECONDS * 1000)
421 h.show()
422 h.input.poll(100 + HOLD_SECONDS * 1000 + 16)
423 expect(h.log.filter((l) => l.startsWith('press'))).toEqual(['press move 0'])
424 expect(h.log.some((l) => l.startsWith('hold 1'))).toBe(false)
425 })
426
427 it('is cancelled when the window loses focus', () => {
428 const t = setup()
429 const log: string[] = []
430 let focused = true
431 let frame = pad()
432 const input = createPadInput({ ...t.targets, holdEnd: () => log.push('end'), press: () => log.push('press') }, () => [frame], () => focused)
433 input.poll(0)
434 frame = pad({}, [0])
435 input.poll(100)
436 focused = false
437 input.poll(200)
438 focused = true
439 frame = pad()
440 input.poll(5000)
441 expect(log).toEqual(['end'])
442 })
443
444 it('is cancelled, ring gone, when the pad is unplugged mid-hold', () => {
445 const t = setup()
446 const log: string[] = []
447 let frame: PadLike | null = pad()
448 const input = createPadInput({ ...t.targets, holdEnd: () => log.push('end'), press: () => log.push('press') }, () => [frame], () => true)
449 input.poll(0)
450 frame = pad({}, [0])
451 input.poll(100)
452 frame = null
453 input.poll(HOLD_SECONDS * 1000 + 200)
454 frame = pad()
455 input.poll(HOLD_SECONDS * 1000 + 300)
456 expect(log).toEqual(['end'])
457 })
458
459 it('is cancelled by an instant button, so nothing fires on what that opened', () => {
460 const h = held()
461 h.show(0)
462 h.input.poll(100)
463 h.show(0, 9)
464 h.input.poll(200)
465 h.show(0)
466 h.input.poll(100 + HOLD_SECONDS * 1000)
467 h.show()
468 h.input.poll(100 + HOLD_SECONDS * 1000 + 16)
469 expect(h.log.filter((l) => l.startsWith('press'))).toEqual(['press menu undefined'])
470 expect(h.log.filter((l) => l === 'end')).toHaveLength(1)
471 })
472
473 it('is cancelled when the place the buttons act in changes', () => {
474 const t = setup()
475 const log: string[] = []
476 let where = 'playing'
477 let frame = pad()
478 const input = createPadInput({ ...t.targets, holdEnd: () => log.push('end'), press: () => log.push('press') }, () => [frame], () => true, () => where)
479 input.poll(0)
480 frame = pad({}, [0])
481 input.poll(100)
482 where = 'dialog'
483 input.poll(200)
484 input.poll(HOLD_SECONDS * 1000 + 300)
485 frame = pad()
486 input.poll(HOLD_SECONDS * 1000 + 400)
487 expect(log).toEqual(['end'])
488 })
489
490 it('ends the ring when reading the pad throws mid-hold', () => {
491 const t = setup()
492 const log: string[] = []
493 let frame = pad()
494 let boom = false
495 const input = createPadInput(
496 { ...t.targets, holdEnd: () => log.push('end'), press: () => log.push('press'), error: () => log.push('error') },
497 () => {
498 if (boom) throw new Error('lost')
499 return [frame]
500 },
501 () => true,
502 )
503 input.poll(0)
504 frame = pad({}, [0])
505 input.poll(100)
506 boom = true
507 input.poll(200)
508 expect(log).toEqual(['end', 'error'])
509 })
510
511 it('is cancelled when the pad is swapped', () => {
512 const t = setup()
513 const log: string[] = []
514 let frame = pad()
515 const input = createPadInput({ ...t.targets, holdEnd: () => log.push('end'), press: () => log.push('press') }, () => [frame], () => true)
516 input.poll(0)
517 frame = pad({}, [0])
518 input.poll(100)
519 frame = pad({ index: 1, id: 'Other' }, [0])
520 input.poll(HOLD_SECONDS * 1000 + 200)
521 frame = pad({ index: 1, id: 'Other' })
522 input.poll(HOLD_SECONDS * 1000 + 300)
523 expect(log).toEqual(['end'])
524 })
525})