explodorama / files / test / padMap.test.ts
padMap.test.ts
ebcd0a8f 2026-11-02 Pavel Safronov | 1 | import { describe, expect, it } from 'vitest' |
92cc4ee3 2026-11-02 Pavel Safronov | 2 | import { cancelFace, confirmFace, faceActionOf, faceOf, LEFT_STICK_ENABLED, mapPad as mapPadWith, newPadMemory, padFamily, REPEAT_DELAY_MS, REPEAT_MS, type PadIntent, type PadMemory, type PadSnapshot } from '../src/ui/padMap' |
ebcd0a8f 2026-11-02 Pavel Safronov | 3 | |
| 4 | // Tests of the left stick's code run with it on; the default (off) has its own test below. | |
| 5 | const mapPad = (s: PadSnapshot, m: PadMemory, t: number): PadIntent[] => mapPadWith(s, m, t, true) | |
| 6 | ||
| 7 | function snap(pressed: number[] = [], axes: number[] = [0, 0, 0, 0]): PadSnapshot { | |
| 8 | const buttons = Array.from({ length: 17 }, (_, i) => pressed.includes(i)) | |
| 9 | return { buttons, axes, standard: true } | |
| 10 | } | |
| 11 | // A memory that has already seen its first (waking) frame, as mapPad expects in play. | |
| 12 | function ready(): PadMemory { | |
| 13 | const mem = newPadMemory() | |
| 14 | mapPad(snap(), mem, -1000) | |
| 15 | return mem | |
| 16 | } | |
| 17 | const UP = 12 | |
| 18 | const DOWN = 13 | |
| 19 | const LEFT = 14 | |
| 20 | const RIGHT = 15 | |
| 21 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 22 | describe('Nintendo: A (right) is Move/Go, B (bottom) is Wait', () => { |
| 23 | const nintendo = (pressed: number[]): PadIntent[] => mapPadWith(snap(pressed), (() => { const m = newPadMemory(); mapPadWith(snap(), m, -1000, true, 'nintendo'); return m })(), 0, true, 'nintendo') | |
| 24 | it('face 1 (right) is move, face 0 (bottom) is wait; bomb and charge stay left and top', () => { | |
| 25 | expect(nintendo([1])).toEqual([{ kind: 'press', button: 'move', face: 1 }]) | |
| 26 | expect(nintendo([0])).toEqual([{ kind: 'press', button: 'wait', face: 0 }]) | |
| 27 | expect(nintendo([2])).toEqual([{ kind: 'press', button: 'bomb', face: 2 }]) | |
| 28 | expect(nintendo([3])).toEqual([{ kind: 'press', button: 'charge', face: 3 }]) | |
| 29 | }) | |
| 30 | it('the other buttons are the same on every family', () => { | |
| 31 | for (const [i, button] of [[4, 'cancel'], [5, 'info'], [8, 'info'], [9, 'menu'], [11, 'reset']] as const) expect(nintendo([i])).toEqual([{ kind: 'press', button }]) | |
| 32 | }) | |
| 33 | it('xbox, playstation and generic keep the position mapping', () => { | |
| 34 | for (const family of ['xbox', 'playstation', 'generic'] as const) { | |
| 35 | const m = newPadMemory() | |
| 36 | mapPadWith(snap(), m, -1000, true, family) | |
| 37 | expect(mapPadWith(snap([0]), m, 0, true, family)).toEqual([{ kind: 'press', button: 'move', face: 0 }]) | |
| 38 | expect(faceActionOf(family, 1)).toBe('wait') | |
| 39 | } | |
| 40 | }) | |
| 41 | it('confirm and cancel faces per family', () => { | |
| 42 | expect([confirmFace('nintendo'), cancelFace('nintendo')]).toEqual([1, 0]) | |
| 43 | for (const family of ['xbox', 'playstation', 'generic'] as const) expect([confirmFace(family), cancelFace(family)]).toEqual([0, 1]) | |
| 44 | expect([faceOf('nintendo', 'bomb'), faceOf('nintendo', 'charge')]).toEqual([2, 3]) | |
| 45 | }) | |
| 46 | }) | |
| 47 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 48 | describe('buttons', () => { |
| 49 | const cases: [number, string][] = [[0, 'move'], [1, 'wait'], [2, 'bomb'], [3, 'charge'], [4, 'cancel'], [5, 'info'], [8, 'info'], [9, 'menu'], [11, 'reset']] | |
| 50 | for (const [index, button] of cases) { | |
| 51 | it(`button ${index} is ${button}`, () => { | |
| 52 | expect(mapPad(snap([index]), ready(), 0)).toEqual([index < 4 ? { kind: 'press', button, face: index } : { kind: 'press', button }]) | |
| 53 | }) | |
| 54 | } | |
| 55 | it('the triggers and L3 do nothing', () => { | |
| 56 | for (const i of [6, 7, 10]) expect(mapPad(snap([i]), ready(), 0)).toEqual([]) | |
| 57 | }) | |
| 58 | it('fires on the rising edge only', () => { | |
| 59 | const mem = ready() | |
| 60 | expect(mapPad(snap([0]), mem, 0)).toHaveLength(1) | |
| 61 | expect(mapPad(snap([0]), mem, 16)).toEqual([]) | |
| 62 | expect(mapPad(snap([0]), mem, 5000)).toEqual([]) | |
| 63 | expect(mapPad(snap([]), mem, 5016)).toEqual([]) | |
| 64 | expect(mapPad(snap([0]), mem, 5032)).toEqual([{ kind: 'press', button: 'move', face: 0 }]) | |
| 65 | }) | |
| 66 | it('a second button pressed while the first is held fires alone', () => { | |
| 67 | const mem = ready() | |
| 68 | mapPad(snap([0]), mem, 0) | |
| 69 | expect(mapPad(snap([0, 2]), mem, 16)).toEqual([{ kind: 'press', button: 'bomb', face: 2 }]) | |
| 70 | }) | |
| 71 | it('copes with a short buttons array', () => { | |
| 72 | expect(mapPad({ buttons: [], axes: [], standard: true }, ready(), 0)).toEqual([]) | |
| 73 | }) | |
| 74 | }) | |
| 75 | ||
| 76 | describe('d-pad direction and repeat', () => { | |
| 77 | const dirs = (xs: PadIntent[]): PadIntent[] => xs.filter((i) => i.kind === 'dir') | |
| 78 | it('maps each d-pad button to a direction', () => { | |
| 79 | expect(mapPad(snap([UP]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }]) | |
| 80 | expect(mapPad(snap([DOWN]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }]) | |
| 81 | expect(mapPad(snap([LEFT]), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: 0 }]) | |
| 82 | expect(mapPad(snap([RIGHT]), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 83 | }) | |
92cc4ee3 2026-11-02 Pavel Safronov | 84 | it('fires at 0, waits REPEAT_DELAY_MS, then repeats every REPEAT_MS', () => { |
ebcd0a8f 2026-11-02 Pavel Safronov | 85 | const mem = ready() |
| 86 | const fired: number[] = [] | |
92cc4ee3 2026-11-02 Pavel Safronov | 87 | const d = REPEAT_DELAY_MS |
| 88 | for (const t of [0, 16, d - 1, d, d + 16, d + REPEAT_MS - 1, d + REPEAT_MS, d + 2 * REPEAT_MS - 1, d + 2 * REPEAT_MS, d + 3 * REPEAT_MS]) { | |
ebcd0a8f 2026-11-02 Pavel Safronov | 89 | if (dirs(mapPad(snap([RIGHT]), mem, t)).length > 0) fired.push(t) |
| 90 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 91 | expect(fired).toEqual([0, d, d + REPEAT_MS, d + 2 * REPEAT_MS, d + 3 * REPEAT_MS]) |
| 92 | }) | |
| 93 | it('holding the d-pad hops quickly (decisions.md 2026-11-02: the cursor crosses the board without a modifier)', () => { | |
| 94 | expect(REPEAT_DELAY_MS).toBeLessThanOrEqual(250) | |
| 95 | expect(REPEAT_MS).toBeLessThanOrEqual(90) | |
| 96 | expect(REPEAT_MS).toBeGreaterThanOrEqual(60) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 97 | }) |
| 98 | it('releasing resets, so the next press fires at once', () => { | |
| 99 | const mem = ready() | |
| 100 | mapPad(snap([RIGHT]), mem, 0) | |
| 101 | mapPad(snap([]), mem, 100) | |
| 102 | expect(dirs(mapPad(snap([RIGHT]), mem, 150))).toHaveLength(1) | |
92cc4ee3 2026-11-02 Pavel Safronov | 103 | expect(dirs(mapPad(snap([RIGHT]), mem, 150 + REPEAT_DELAY_MS - 1))).toHaveLength(0) |
| 104 | expect(dirs(mapPad(snap([RIGHT]), mem, 150 + REPEAT_DELAY_MS))).toHaveLength(1) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 105 | }) |
| 106 | it('changing direction while held fires at once and restarts the delay', () => { | |
| 107 | const mem = ready() | |
| 108 | mapPad(snap([RIGHT]), mem, 0) | |
| 109 | expect(mapPad(snap([DOWN]), mem, 100)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }]) | |
92cc4ee3 2026-11-02 Pavel Safronov | 110 | expect(dirs(mapPad(snap([DOWN]), mem, 100 + REPEAT_DELAY_MS - 1))).toHaveLength(0) |
| 111 | expect(dirs(mapPad(snap([DOWN]), mem, 100 + REPEAT_DELAY_MS))).toHaveLength(1) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 112 | }) |
| 113 | it('gives diagonals; opposite buttons cancel per axis', () => { | |
| 114 | expect(mapPad(snap([UP, RIGHT]), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: -1 }]) | |
| 115 | expect(mapPad(snap([DOWN, LEFT]), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: 1 }]) | |
| 116 | expect(mapPad(snap([LEFT, RIGHT]), ready(), 0)).toEqual([]) | |
| 117 | expect(mapPad(snap([UP, DOWN]), ready(), 0)).toEqual([]) | |
| 118 | expect(mapPad(snap([UP, LEFT, RIGHT]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }]) | |
| 119 | }) | |
92cc4ee3 2026-11-02 Pavel Safronov | 120 | it('repeats on ~16 ms frames: first repeat within a frame of the delay, then about every REPEAT_MS', () => { |
ebcd0a8f 2026-11-02 Pavel Safronov | 121 | const mem = ready() |
| 122 | const fired: number[] = [] | |
| 123 | for (let t = 0; t <= 800; t += 16) if (dirs(mapPad(snap([RIGHT]), mem, t)).length > 0) fired.push(t) | |
| 124 | expect(fired[0]).toBe(0) | |
92cc4ee3 2026-11-02 Pavel Safronov | 125 | expect(fired[1]).toBeGreaterThanOrEqual(REPEAT_DELAY_MS) |
| 126 | expect(fired[1]).toBeLessThan(REPEAT_DELAY_MS + 16) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 127 | for (let i = 2; i < fired.length; i++) { |
| 128 | const gap = (fired[i] as number) - (fired[i - 1] as number) | |
92cc4ee3 2026-11-02 Pavel Safronov | 129 | expect(gap).toBeGreaterThanOrEqual(REPEAT_MS) |
| 130 | expect(gap).toBeLessThan(REPEAT_MS + 16) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 131 | } |
| 132 | expect(fired.length).toBeGreaterThanOrEqual(5) | |
| 133 | }) | |
| 134 | it('going from the d-pad to the stick in the same direction does not restart; a different one does', () => { | |
| 135 | const mem = ready() | |
| 136 | mapPad(snap([RIGHT]), mem, 0) | |
| 137 | expect(mapPad(snap([], [1, 0, 0, 0]), mem, 100)).toEqual([]) // same direction, delay carries on | |
| 138 | expect(mapPad(snap([], [0, 1, 0, 0]), mem, 150)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }]) | |
92cc4ee3 2026-11-02 Pavel Safronov | 139 | expect(dirs(mapPad(snap([], [0, 1, 0, 0]), mem, 150 + REPEAT_DELAY_MS - 1))).toHaveLength(0) |
| 140 | expect(dirs(mapPad(snap([], [0, 1, 0, 0]), mem, 150 + REPEAT_DELAY_MS))).toHaveLength(1) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 141 | }) |
| 142 | it('a short buttons array can still carry a press at index 15', () => { | |
| 143 | const buttons = Array.from({ length: 16 }, (_, i) => i === 15) | |
| 144 | expect(mapPad({ buttons, axes: [], standard: true }, ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 145 | }) | |
| 146 | }) | |
| 147 | ||
| 148 | describe('releasing a diagonal', () => { | |
| 149 | const dirs = (xs: PadIntent[]): PadIntent[] => xs.filter((i) => i.kind === 'dir') | |
| 150 | it('one button lifting a frame before the other gives no new step', () => { | |
| 151 | const mem = ready() | |
| 152 | expect(mapPad(snap([UP, RIGHT]), mem, 0)).toEqual([{ kind: 'dir', dx: 1, dy: -1 }]) | |
| 153 | expect(mapPad(snap([UP]), mem, 16)).toEqual([]) | |
| 154 | expect(mapPad(snap([]), mem, 32)).toEqual([]) | |
| 155 | // ... and a fresh press afterwards is a step again. | |
| 156 | expect(mapPad(snap([UP]), mem, 48)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }]) | |
| 157 | }) | |
| 158 | it('the axis left does not repeat either, however long it is held', () => { | |
| 159 | const mem = ready() | |
| 160 | mapPad(snap([DOWN, LEFT]), mem, 0) | |
| 161 | mapPad(snap([LEFT]), mem, 16) | |
| 162 | expect(dirs(mapPad(snap([LEFT]), mem, 2000))).toHaveLength(0) | |
| 163 | }) | |
| 164 | it('building a diagonal still fires: the first button, then the second', () => { | |
| 165 | const mem = ready() | |
| 166 | expect(mapPad(snap([UP]), mem, 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }]) | |
| 167 | expect(mapPad(snap([UP, RIGHT]), mem, 16)).toEqual([{ kind: 'dir', dx: 1, dy: -1 }]) | |
| 168 | }) | |
| 169 | it('going to a different direction, not a part, fires', () => { | |
| 170 | const mem = ready() | |
| 171 | mapPad(snap([UP, RIGHT]), mem, 0) | |
| 172 | expect(mapPad(snap([DOWN, RIGHT]), mem, 16)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }]) | |
| 173 | }) | |
| 174 | }) | |
| 175 | ||
| 176 | describe('left stick off', () => { | |
| 177 | it('is off by default and gives nothing, at any push', () => { | |
| 178 | expect(LEFT_STICK_ENABLED).toBe(false) | |
| 179 | for (const axes of [[1, 0, 0, 0], [0, -1, 0, 0], [0.7, 0.7, 0, 0]]) expect(mapPadWith(snap([], axes), ready(), 0)).toEqual([]) | |
| 180 | }) | |
| 181 | it('the d-pad still works', () => { | |
| 182 | expect(mapPadWith(snap([UP], [1, 0, 0, 0]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }]) | |
| 183 | }) | |
| 184 | }) | |
| 185 | ||
| 186 | describe('left stick', () => { | |
| 187 | const stick = (x: number, y: number): PadSnapshot => snap([], [x, y, 0, 0]) | |
| 188 | it('is ignored below the 0.5 threshold', () => { | |
| 189 | expect(mapPad(stick(0.49, 0), ready(), 0)).toEqual([]) | |
| 190 | expect(mapPad(stick(0, -0.49), ready(), 0)).toEqual([]) | |
| 191 | }) | |
| 192 | it('gives four straight directions (y positive is down)', () => { | |
| 193 | expect(mapPad(stick(0.5, 0), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 194 | expect(mapPad(stick(-1, 0), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: 0 }]) | |
| 195 | expect(mapPad(stick(0, 0.9), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }]) | |
| 196 | expect(mapPad(stick(0, -0.9), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }]) | |
| 197 | }) | |
| 198 | it('gives a diagonal when both axes are past the threshold', () => { | |
| 199 | expect(mapPad(stick(0.7, 0.7), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }]) | |
| 200 | expect(mapPad(stick(-0.7, -0.6), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: -1 }]) | |
| 201 | // Sectors are 45 degrees: a push 18 degrees below right is still right; 30 degrees is the diagonal. | |
| 202 | expect(mapPad(stick(0.9, 0.3), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 203 | expect(mapPad(stick(0.8, 0.46), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }]) | |
| 204 | expect(mapPad(stick(0.3, 0.9), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }]) | |
| 205 | }) | |
| 206 | it('judges the vector, not each axis: both axes under 0.5 can still be pushed far enough', () => { | |
| 207 | expect(mapPad(stick(0.45, 0.45), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }]) | |
| 208 | }) | |
| 209 | it('hysteresis: stays on until it falls to 0.35', () => { | |
| 210 | const mem = ready() | |
| 211 | expect(mapPad(stick(0.6, 0), mem, 0)).toHaveLength(1) | |
| 212 | // 0.4 is under the on-threshold but over the off-threshold: still held, so no new fire. | |
| 213 | expect(mapPad(stick(0.4, 0), mem, 50)).toEqual([]) | |
| 214 | expect(mem.heldDir).toBe('1,0') | |
| 215 | expect(mapPad(stick(0.36, 0), mem, 60)).toEqual([]) | |
| 216 | expect(mem.heldDir).toBe('1,0') | |
| 217 | mapPad(stick(0.35, 0), mem, 70) | |
| 218 | expect(mem.heldDir).toBe('') | |
| 219 | // Back at 0.4 it is not on again: it must pass 0.5 first. | |
| 220 | expect(mapPad(stick(0.4, 0), mem, 80)).toEqual([]) | |
| 221 | expect(mapPad(stick(0.5, 0), mem, 90)).toHaveLength(1) | |
| 222 | }) | |
| 223 | it('a held stick changes sector only past 30 degrees from the sector centre', () => { | |
| 224 | const mem = ready() | |
| 225 | const at = (deg: number): PadSnapshot => stick(0.8 * Math.cos((deg * Math.PI) / 180), 0.8 * Math.sin((deg * Math.PI) / 180)) | |
| 226 | expect(mapPad(at(0), mem, 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 227 | // 29 degrees would be the diagonal from rest, but while held right it stays right. | |
| 228 | expect(mapPad(at(29), mem, 10)).toEqual([]) | |
| 229 | expect(mem.heldDir).toBe('1,0') | |
| 230 | expect(mapPad(at(31), mem, 20)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }]) | |
| 231 | // And back: the diagonal holds down to 15 degrees. | |
| 232 | expect(mapPad(at(20), mem, 30)).toEqual([]) | |
| 233 | expect(mem.heldDir).toBe('1,1') | |
| 234 | }) | |
| 235 | it('rolling right to down passes through the diagonal, one step each, no flicker', () => { | |
| 236 | const mem = ready() | |
| 237 | const steps: string[] = [] | |
| 238 | for (let deg = 0; deg <= 90; deg += 5) { | |
| 239 | const r = (deg * Math.PI) / 180 | |
| 240 | for (const i of mapPad(stick(0.8 * Math.cos(r), 0.8 * Math.sin(r)), mem, deg)) if (i.kind === 'dir') steps.push(`${i.dx},${i.dy}`) | |
| 241 | } | |
| 242 | expect(steps).toEqual(['1,0', '1,1', '0,1']) | |
| 243 | }) | |
| 244 | it('the opposite side switches sector at once (held right, pushed left)', () => { | |
| 245 | const mem = ready() | |
| 246 | mapPad(stick(0.6, 0), mem, 0) | |
| 247 | expect(mapPad(stick(-0.4, 0), mem, 10)).toEqual([{ kind: 'dir', dx: -1, dy: 0 }]) | |
| 248 | }) | |
| 249 | it('a push to a diagonal and back fires once: the sector the stick is in when it first passes 0.5', () => { | |
| 250 | // Chosen: the first fire is whatever sector it is in at the first frame past 0.5 (here the | |
| 251 | // diagonal, 42.7 degrees), and easing back to rest through the band adds nothing. | |
| 252 | const mem = ready() | |
| 253 | const fired: PadIntent[] = [] | |
| 254 | const frames: [number, number][] = [[0.3, 0.28], [0.52, 0.48], [0.7, 0.7], [0.5, 0.3], [0.3, 0.1], [0, 0]] | |
| 255 | frames.forEach(([x, y], i) => fired.push(...mapPad(stick(x, y), mem, i * 16))) | |
| 256 | expect(fired).toEqual([{ kind: 'dir', dx: 1, dy: 1 }]) | |
| 257 | expect(mem.heldDir).toBe('') | |
| 258 | }) | |
| 259 | it('non-finite stick values count as zero', () => { | |
| 260 | expect(mapPad(snap([], [NaN, Infinity, 0, 0]), ready(), 0)).toEqual([]) | |
| 261 | }) | |
| 262 | it('works with only the x axis present', () => { | |
| 263 | expect(mapPad({ buttons: [], axes: [1], standard: true }, ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 264 | }) | |
| 265 | it('repeats like the d-pad', () => { | |
| 266 | const mem = ready() | |
| 267 | const fired: number[] = [] | |
92cc4ee3 2026-11-02 Pavel Safronov | 268 | const d = REPEAT_DELAY_MS |
| 269 | for (const t of [0, d - 1, d, d + REPEAT_MS - 1, d + REPEAT_MS]) if (mapPad(stick(1, 0), mem, t).length > 0) fired.push(t) | |
| 270 | expect(fired).toEqual([0, d, d + REPEAT_MS]) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 271 | }) |
| 272 | it('the d-pad takes precedence over the stick', () => { | |
| 273 | const mem = ready() | |
| 274 | expect(mapPad(snap([UP], [1, 0, 0, 0]), mem, 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }]) | |
| 275 | expect(mapPad(snap([], [1, 0, 0, 0]), mem, 20)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 276 | }) | |
| 277 | it('copes with missing axes', () => { | |
| 278 | expect(mapPad({ buttons: [], axes: [], standard: true }, ready(), 0)).toEqual([]) | |
| 279 | }) | |
| 280 | }) | |
| 281 | ||
| 282 | describe('right stick', () => { | |
| 283 | const look = (x: number, y: number): PadSnapshot => snap([], [0, 0, x, y]) | |
| 284 | it('does nothing inside the 0.2 deadzone, even before it ever looked', () => { | |
| 285 | expect(mapPad(look(0.1, 0.1), ready(), 0)).toEqual([]) | |
| 286 | expect(mapPad(look(0.2, 0), ready(), 0)).toEqual([]) | |
| 287 | }) | |
| 288 | it('the deadzone is radial, not per axis', () => { | |
| 289 | // Each axis is at or under 0.2, but together they are 0.28 out (past the deadzone); 0.14 each is 0.198 (inside). | |
| 290 | expect(mapPad(look(0.2, 0.2), ready(), 0)).toHaveLength(1) | |
| 291 | expect(mapPad(look(0.14, 0.14), ready(), 0)).toEqual([]) | |
| 292 | }) | |
| 293 | it('non-finite values count as zero', () => { | |
| 294 | expect(mapPad(look(NaN, Infinity), ready(), 0)).toEqual([]) | |
| 295 | expect(mapPad(look(NaN, 1), ready(), 0)).toHaveLength(1) | |
| 296 | }) | |
| 297 | it('scales from 0 at the deadzone edge to 1 at full tilt', () => { | |
| 298 | const full = mapPad(look(1, 0), ready(), 0) | |
| 299 | expect(full).toEqual([{ kind: 'look', dx: 1, dy: 0 }]) | |
| 300 | const half = mapPad(look(0.6, 0), ready(), 0)[0] | |
| 301 | expect(half).toMatchObject({ kind: 'look', dy: 0 }) | |
| 302 | expect((half as { dx: number }).dx).toBeCloseTo(0.5) | |
| 303 | const down = mapPad(look(0, -1), ready(), 0)[0] as { dx: number; dy: number } | |
| 304 | expect(down.dx).toBeCloseTo(0) | |
| 305 | expect(down.dy).toBeCloseTo(-1) | |
| 306 | }) | |
| 307 | it('stays within -1..1 on a corner push', () => { | |
| 308 | const l = mapPad(look(1, 1), ready(), 0)[0] as { dx: number; dy: number } | |
| 309 | expect(Math.abs(l.dx)).toBeLessThanOrEqual(1) | |
| 310 | expect(Math.abs(l.dy)).toBeLessThanOrEqual(1) | |
| 311 | expect(l.dx).toBeCloseTo(l.dy) | |
| 312 | }) | |
| 313 | it('emits look every frame, then one lookEnd on return', () => { | |
| 314 | const mem: PadMemory = ready() | |
| 315 | expect(mapPad(look(1, 0), mem, 0)).toHaveLength(1) | |
| 316 | expect(mapPad(look(1, 0), mem, 16)).toHaveLength(1) | |
| 317 | expect(mapPad(look(0, 0), mem, 32)).toEqual([{ kind: 'lookEnd' }]) | |
| 318 | expect(mapPad(look(0, 0), mem, 48)).toEqual([]) | |
| 319 | }) | |
| 320 | }) | |
| 321 | ||
| 322 | describe('the first frame (waking the pad)', () => { | |
| 323 | it('records what is down but emits no press or dir', () => { | |
| 324 | const mem = newPadMemory() | |
| 325 | expect(mapPad(snap([0, RIGHT]), mem, 0)).toEqual([]) | |
| 326 | expect(mem.primed).toBe(true) | |
| 327 | expect(mapPad(snap([0, RIGHT]), mem, 16)).toEqual([]) // still the same press | |
| 328 | }) | |
| 329 | it('a direction held through the wake does not repeat, until released', () => { | |
| 330 | const mem = newPadMemory() | |
| 331 | mapPad(snap([RIGHT]), mem, 0) | |
| 332 | expect(mapPad(snap([RIGHT]), mem, 1000)).toEqual([]) | |
| 333 | mapPad(snap([]), mem, 1016) | |
| 334 | expect(mapPad(snap([RIGHT]), mem, 1032)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }]) | |
| 335 | }) | |
| 336 | it('the next press after the wake fires', () => { | |
| 337 | const mem = newPadMemory() | |
| 338 | mapPad(snap([0]), mem, 0) | |
| 339 | mapPad(snap([]), mem, 16) | |
| 340 | expect(mapPad(snap([0]), mem, 32)).toEqual([{ kind: 'press', button: 'move', face: 0 }]) | |
| 341 | }) | |
| 342 | }) | |
| 343 | ||
| 344 | describe('padFamily: Nintendo', () => { | |
| 345 | it('recognises Switch pads by name or vendor', () => { | |
| 346 | expect(padFamily('Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)')).toBe('nintendo') | |
| 347 | expect(padFamily('Nintendo Switch Pro Controller')).toBe('nintendo') | |
| 348 | expect(padFamily('057e-2006-Joy-Con (L)')).toBe('nintendo') | |
| 349 | expect(padFamily('Switch Controller')).toBe('nintendo') | |
| 350 | }) | |
| 351 | }) | |
| 352 | ||
| 353 | describe('padFamily', () => { | |
| 354 | it('recognises Xbox and XInput pads', () => { | |
| 355 | expect(padFamily('Xbox 360 Controller (STANDARD GAMEPAD Vendor: 045e Product: 028e)')).toBe('xbox') | |
| 356 | expect(padFamily('Microsoft Controller (XInput STANDARD GAMEPAD)')).toBe('xbox') | |
| 357 | expect(padFamily('XBOX One')).toBe('xbox') | |
| 358 | }) | |
| 359 | it('recognises PlayStation pads', () => { | |
| 360 | expect(padFamily('DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)')).toBe('playstation') | |
| 361 | expect(padFamily('Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)')).toBe('playstation') | |
| 362 | expect(padFamily('054c-09cc-Sony Controller')).toBe('playstation') | |
| 363 | expect(padFamily('PLAYSTATION(R)3 Controller')).toBe('playstation') | |
| 364 | expect(padFamily('DualShock 4')).toBe('playstation') | |
| 365 | }) | |
| 366 | it('anything else is generic', () => { | |
| 367 | expect(padFamily('8BitDo Pro 2 (Vendor: 2dc8 Product: 6003)')).toBe('generic') | |
| 368 | expect(padFamily('Wireless Controller')).toBe('generic') | |
| 369 | expect(padFamily('')).toBe('generic') | |
| 370 | }) | |
| 371 | }) |