import { ControlId, DEFAULT_LAYOUT, MultiTouchRouter, applySafeArea, classifyDirection, hitTest, isInsideCircle, isInsideRect, joystickDirection, ZERO_DIRECTION, } from '@ui/InputModel'; describe('InputModel — layout geometry', () => { it('default landscape layout places joystick on left, attacks on right', () => { expect(DEFAULT_LAYOUT.joystick.cx).toBeLessThan(480); expect(DEFAULT_LAYOUT.shuriken.cx).toBeGreaterThan(480); expect(DEFAULT_LAYOUT.ninjaSword.cx).toBeGreaterThan(DEFAULT_LAYOUT.shuriken.cx); }); it('isInsideRect is correct for corners and near-misses', () => { const r = { cx: 100, cy: 100, w: 40, h: 40 }; expect(isInsideRect(r, 100, 100)).toBe(true); expect(isInsideRect(r, 120, 120)).toBe(true); // With HIT_TOLERANCE=15, the effective boundary is ±35 (=halfW/halfH + tolerance). // 121 is outside the visual box (120) but within tolerance (135). expect(isInsideRect(r, 121, 100)).toBe(true); // 79 is below the visual bottom (80) but within tolerance (65). expect(isInsideRect(r, 100, 79)).toBe(true); // Far outside even the tolerance range. expect(isInsideRect(r, 136, 100)).toBe(false); expect(isInsideRect(r, 100, 64)).toBe(false); }); it('isInsideCircle matches circular button shape', () => { const r = { cx: 765, cy: 100, w: 90, h: 90 }; // shuriken button // Centre — always a hit. expect(isInsideCircle(r, 765, 100)).toBe(true); // On the circle edge (radius = 45). expect(isInsideCircle(r, 765 + 45, 100)).toBe(true); // right edge expect(isInsideCircle(r, 765, 100 + 45)).toBe(true); // top edge (upper semicircle) expect(isInsideCircle(r, 765 - 45, 100)).toBe(true); // left edge expect(isInsideCircle(r, 765, 100 - 45)).toBe(true); // bottom edge // Within HIT_TOLERANCE (15) outside the circle. expect(isInsideCircle(r, 765 + 60, 100)).toBe(true); // right + tolerance expect(isInsideCircle(r, 765, 100 + 60)).toBe(true); // top + tolerance (upper arc) // Corner of the bounding rect but OUTSIDE the circle — should miss. // Distance from centre to (765+45, 100+45) = sqrt(45²+45²) ≈ 63.6 > 45+15=60 expect(isInsideCircle(r, 765 + 45, 100 + 45)).toBe(false); // Well outside. expect(isInsideCircle(r, 765 + 70, 100)).toBe(false); }); }); describe('InputModel — hitTest priority', () => { it('routes a finger pressing both an attack and the joystick to the attack', () => { // Arrange: stretch the joystick rect so it overlaps the shuriken button. const layout = { ...DEFAULT_LAYOUT, joystick: { cx: 120, cy: 100, w: 900, h: 200 }, }; const id = hitTest(layout, layout.shuriken.cx, layout.shuriken.cy); expect(id).toBe(ControlId.Shuriken); }); it('returns null when the touch misses every control', () => { expect(hitTest(DEFAULT_LAYOUT, 480, 400)).toBeNull(); }); }); describe('InputModel — joystick dead zone (req 1.5)', () => { it('returns ZERO_DIRECTION inside the 10px dead-zone', () => { const dir = joystickDirection(DEFAULT_LAYOUT, DEFAULT_LAYOUT.joystick.cx + 4, DEFAULT_LAYOUT.joystick.cy - 3); expect(dir).toBe(ZERO_DIRECTION); }); it('returns a normalised vector with magnitude > deadzone outside it', () => { const dir = joystickDirection( DEFAULT_LAYOUT, DEFAULT_LAYOUT.joystick.cx + 60, DEFAULT_LAYOUT.joystick.cy + 0 ); expect(dir.magnitude).toBeCloseTo(60); expect(dir.x).toBeCloseTo(1); expect(dir.y).toBeCloseTo(0); }); }); describe('InputModel — parabolic angle classification (req 2.5, 20.3)', () => { it.each([ [45, 'parabolic_right'], [50, 'parabolic_right'], [40, 'parabolic_right'], [60, 'parabolic_right'], [135, 'parabolic_left'], [140, 'parabolic_left'], [120, 'parabolic_left'], [0, 'horizontal'], [180, 'horizontal'], [90, 'other'], ] as Array<[number, string]>)('degree %p → %p', (deg, klass) => { const rad = (deg * Math.PI) / 180; const dir = { x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 }; expect(classifyDirection(dir)).toBe(klass); }); it('hits ≥95% recognition rate when sampling evenly around 45° ± 15°', () => { let hits = 0; const samples = 200; for (let i = 0; i < samples; i++) { const deg = 30 + (i / samples) * 30; // 30°..60° const rad = (deg * Math.PI) / 180; const k = classifyDirection({ x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 }); if (k === 'parabolic_right') hits++; } expect(hits / samples).toBeGreaterThanOrEqual(0.95); }); }); describe('InputModel — applySafeArea (req 1.7, 18.6)', () => { it('slides left-group rightwards and right-group leftwards by the insets', () => { const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 40, right: 60, top: 0, bottom: 0 }); expect(shifted.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 40); expect(shifted.jump.cx).toBe(DEFAULT_LAYOUT.jump.cx + 40); expect(shifted.shuriken.cx).toBe(DEFAULT_LAYOUT.shuriken.cx - 60); expect(shifted.ninjaSword.cx).toBe(DEFAULT_LAYOUT.ninjaSword.cx - 60); }); it('keeps every control inside the visible area after shifting', () => { const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 20, right: 20, top: 20, bottom: 20 }); const all = [shifted.joystick, shifted.jump, shifted.shuriken, shifted.ninjaSword]; for (const r of all) { expect(r.cx + r.w / 2).toBeLessThanOrEqual(960); expect(r.cx - r.w / 2).toBeGreaterThanOrEqual(0); expect(r.cy + r.h / 2).toBeLessThanOrEqual(540); expect(r.cy - r.h / 2).toBeGreaterThanOrEqual(0); } }); }); describe('MultiTouchRouter — req 1.8 (≥3 simultaneous touches)', () => { it('routes three fingers to joystick + jump + shuriken independently', () => { const router = new MultiTouchRouter(DEFAULT_LAYOUT); const j = router.begin(0, DEFAULT_LAYOUT.joystick.cx, DEFAULT_LAYOUT.joystick.cy, 0); const p = router.begin(1, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 10); const s = router.begin(2, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 20); expect(j).toBe(ControlId.Joystick); expect(p).toBe(ControlId.Jump); expect(s).toBe(ControlId.Shuriken); expect(router.activeTouchCount).toBe(3); expect(router.isPressed(ControlId.Jump)).toBe(true); }); it('end() returns the previously-bound control and removes the slot', () => { const router = new MultiTouchRouter(DEFAULT_LAYOUT); router.begin(5, DEFAULT_LAYOUT.ninjaSword.cx, DEFAULT_LAYOUT.ninjaSword.cy, 0); expect(router.end(5)).toBe(ControlId.NinjaSword); expect(router.isPressed(ControlId.NinjaSword)).toBe(false); }); it('falls through (returns null) when the touch lands outside all controls (req 1.3)', () => { const router = new MultiTouchRouter(DEFAULT_LAYOUT); expect(router.begin(7, 480, 400, 0)).toBeNull(); }); it('earliestPressTs returns the oldest timestamp among the given controls', () => { const router = new MultiTouchRouter(DEFAULT_LAYOUT); router.begin(0, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 100); router.begin(1, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 80); expect(router.earliestPressTs([ControlId.Jump, ControlId.Shuriken])).toBe(80); expect(router.earliestPressTs([ControlId.NinjaSword])).toBeUndefined(); }); });