explodorama / files / test / gamepad.test.ts
gamepad.test.ts
ebcd0a8f 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
92cc4ee3 2026-11-02 Pavel Safronov | 2 | import { HOLD_SECONDS } from '../src/ui/padHold' |
ebcd0a8f 2026-11-02 Pavel Safronov | 3 | import { createPadInput, type PadLike, type PadTargets } from '../src/ui/gamepad' |
| 4 | ||
| 5 | function 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 | ||
| 17 | function 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 | ||
| 33 | describe('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 Safronov | 56 | // 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 Safronov | 58 | expect(t.calls).toContain('dir 1,0') |
92cc4ee3 2026-11-02 Pavel Safronov | 59 | 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 Safronov | 64 | t.calls.length = 0 |
| 65 | t.set(pad({ axes: [0, 0, 1, 0] })) | |
92cc4ee3 2026-11-02 Pavel Safronov | 66 | t.input.poll(1000) |
ebcd0a8f 2026-11-02 Pavel Safronov | 67 | expect(t.calls).toContain('look 1.00,0.00') |
| 68 | t.set(pad()) | |
92cc4ee3 2026-11-02 Pavel Safronov | 69 | t.input.poll(1016) |
ebcd0a8f 2026-11-02 Pavel Safronov | 70 | 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 | ||
| 191 | describe('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 Safronov | 201 | // 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 Safronov | 205 | input2.poll(0) |
92cc4ee3 2026-11-02 Pavel Safronov | 206 | 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 Safronov | 214 | expect(faces).toEqual([{ name: 'move', face: 0 }, { name: 'charge', face: 3 }]) |
| 215 | }) | |
| 216 | }) | |
| 217 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 218 | describe('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 Safronov | 239 | describe('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 Safronov | 249 | |
| 250 | describe('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 Safronov | 391 | 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 Safronov | 413 | 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 | }) |