148 lines
6.2 KiB
TypeScript
148 lines
6.2 KiB
TypeScript
import {
|
|
ControlId,
|
|
DEFAULT_LAYOUT,
|
|
MultiTouchRouter,
|
|
applySafeArea,
|
|
classifyDirection,
|
|
hitTest,
|
|
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);
|
|
expect(isInsideRect(r, 121, 100)).toBe(false);
|
|
expect(isInsideRect(r, 100, 79)).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();
|
|
});
|
|
});
|