import { DEFAULT_LAYOUT_DELTA, LAYOUT_DELTA_BOUNDS, LayoutCustomizer, applyLayoutDelta, sanitiseLayoutDelta, } from '@ui/LayoutCustomizer'; import { DEFAULT_LAYOUT } from '@ui/InputModel'; import { StorageMgr } from '@common/StorageMgr'; import { STORAGE_KEY } from '@common/Constants'; function mem() { const m = new Map(); return { getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null), setItem: (k: string, v: string) => { m.set(k, v); }, removeItem: (k: string) => { m.delete(k); }, }; } describe('sanitiseLayoutDelta', () => { it('returns a defensive copy of the default when given null', () => { const d = sanitiseLayoutDelta(null); expect(d).toEqual(DEFAULT_LAYOUT_DELTA); d.opacity = 0.1; expect(DEFAULT_LAYOUT_DELTA.opacity).toBe(0.7); // ensure we did not mutate }); it('clamps offset beyond the allowed range', () => { const d = sanitiseLayoutDelta({ joystickOffset: { dx: 9999, dy: -9999 } }); expect(d.joystickOffset.dx).toBe(LAYOUT_DELTA_BOUNDS.offsetPxMax); expect(d.joystickOffset.dy).toBe(-LAYOUT_DELTA_BOUNDS.offsetPxMax); }); it('clamps size scale and opacity to allowed ranges', () => { const d = sanitiseLayoutDelta({ buttonSizeScale: 5, opacity: 2 }); expect(d.buttonSizeScale).toBe(LAYOUT_DELTA_BOUNDS.sizeScaleMax); expect(d.opacity).toBe(LAYOUT_DELTA_BOUNDS.opacityMax); }); it('replaces NaN-like inputs with safe midpoints', () => { const d = sanitiseLayoutDelta({ buttonSizeScale: NaN }); expect(d.buttonSizeScale).toBeGreaterThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMin); expect(d.buttonSizeScale).toBeLessThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMax); }); }); describe('applyLayoutDelta', () => { it('produces a layout identical to baseline when delta is default', () => { const result = applyLayoutDelta(DEFAULT_LAYOUT, DEFAULT_LAYOUT_DELTA); expect(result.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx); expect(result.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity); }); it('shifts centres and scales widths', () => { const delta = sanitiseLayoutDelta({ joystickOffset: { dx: 30, dy: 10 }, buttonSizeScale: 1.2, opacity: 0.9, }); const r = applyLayoutDelta(DEFAULT_LAYOUT, delta); expect(r.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 30); expect(r.joystick.cy).toBe(DEFAULT_LAYOUT.joystick.cy + 10); expect(r.shuriken.w).toBeCloseTo(DEFAULT_LAYOUT.shuriken.w * 1.2); expect(r.opacity).toBe(0.9); }); }); describe('LayoutCustomizer — persistence', () => { it('returns the default layout when nothing is stored (req 17.6)', () => { const cust = new LayoutCustomizer(DEFAULT_LAYOUT, new StorageMgr(mem())); const { delta, layout } = cust.loadLayout(); expect(delta).toEqual(DEFAULT_LAYOUT_DELTA); expect(layout.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx); }); it('round-trips a custom delta through saveDelta() / loadLayout()', () => { const storage = new StorageMgr(mem()); const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); cust.saveDelta({ joystickOffset: { dx: 20, dy: -15 }, jumpOffset: { dx: 0, dy: 0 }, shurikenOffset: { dx: -10, dy: 5 }, ninjaSwordOffset: { dx: 0, dy: 0 }, buttonSizeScale: 1.1, opacity: 0.85, }); const { delta } = cust.loadLayout(); expect(delta.joystickOffset.dx).toBe(20); expect(delta.shurikenOffset.dx).toBe(-10); expect(delta.opacity).toBe(0.85); }); it('reset() clears the stored layout', () => { const storage = new StorageMgr(mem()); const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); cust.saveDelta({ ...DEFAULT_LAYOUT_DELTA, opacity: 1.0 }); cust.reset(); const { delta } = cust.loadLayout(); expect(delta.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity); }); it('falls back to defaults when storage returns corrupted JSON (req 17.6)', () => { const driver = { getItem: () => 'not valid json', setItem: () => {}, removeItem: () => {}, }; const storage = new StorageMgr(driver); const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); const { delta } = cust.loadLayout(); expect(delta).toEqual(DEFAULT_LAYOUT_DELTA); }); it('uses the kl_control_layout storage key (req 17.2)', () => { const driver = mem(); const setSpy = jest.fn(driver.setItem); driver.setItem = setSpy; const storage = new StorageMgr(driver); const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); cust.saveDelta(DEFAULT_LAYOUT_DELTA); expect(setSpy).toHaveBeenCalled(); expect(setSpy.mock.calls[0][0]).toBe(STORAGE_KEY.ControlLayout); }); });