import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { act } from 'react-dom/test-utils'; import { useIsMobile } from './useIsMobile'; /** * Mobile-A2 (review item 5): useIsMobile is the single source of truth every * mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads * from, so it needs direct coverage of: (a) it reflects matchMedia's * `matches`, (b) the `change` listener is registered AND cleaned up on * unmount (a leaked listener would keep re-rendering an unmounted tree / * leak the component instance), and (c) the legacy Safari<14 * addListener/removeListener fallback is used when addEventListener isn't * available. * * DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx / * useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library * (not a project dependency). */ let container: HTMLDivElement; let root: Root; let originalMatchMedia: typeof window.matchMedia; function render(ui: React.ReactElement) { container = document.createElement('div'); document.body.appendChild(container); act(() => { root = createRoot(container); root.render(ui); }); } function unmount() { act(() => { root.unmount(); }); container.remove(); } /** A minimal fake MediaQueryList. `mode` picks which listener API it * exposes, so tests can force the legacy fallback path. */ function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') { const listeners: Array<() => void> = []; const mql: any = { matches, media: '(max-width: 768px)', }; if (mode === 'modern') { mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn)); mql.removeEventListener = vi.fn((_type: string, fn: () => void) => { const i = listeners.indexOf(fn); if (i !== -1) listeners.splice(i, 1); }); } else { mql.addListener = vi.fn((fn: () => void) => listeners.push(fn)); mql.removeListener = vi.fn((fn: () => void) => { const i = listeners.indexOf(fn); if (i !== -1) listeners.splice(i, 1); }); } return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length }; } let probedValue: boolean | null = null; const Probe: React.FC = () => { probedValue = useIsMobile(); return ; }; beforeEach(() => { originalMatchMedia = window.matchMedia; probedValue = null; }); afterEach(() => { window.matchMedia = originalMatchMedia; }); describe('useIsMobile', () => { test('returns true when the mobile media query matches', () => { const { mql } = makeMql(true); window.matchMedia = vi.fn(() => mql) as any; render(); expect(probedValue).toBe(true); unmount(); }); test('returns false when the mobile media query does not match', () => { const { mql } = makeMql(false); window.matchMedia = vi.fn(() => mql) as any; render(); expect(probedValue).toBe(false); unmount(); }); test('registers the change listener via addEventListener and updates on change', () => { const { mql, fire, listenerCount } = makeMql(false); window.matchMedia = vi.fn(() => mql) as any; render(); expect(probedValue).toBe(false); expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); expect(listenerCount()).toBe(1); // Flip the query result and fire the registered 'change' listener -- // the hook must re-read mql.matches, not just toggle blindly. mql.matches = true; act(() => { fire(); }); expect(probedValue).toBe(true); unmount(); }); test('cleans up the addEventListener listener on unmount', () => { const { mql, listenerCount } = makeMql(true); window.matchMedia = vi.fn(() => mql) as any; render(); expect(listenerCount()).toBe(1); unmount(); expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); expect(listenerCount()).toBe(0); }); test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => { const { mql, fire, listenerCount } = makeMql(false, 'legacy'); window.matchMedia = vi.fn(() => mql) as any; render(); expect(probedValue).toBe(false); expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function)); expect(listenerCount()).toBe(1); mql.matches = true; act(() => { fire(); }); expect(probedValue).toBe(true); unmount(); expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function)); expect(listenerCount()).toBe(0); }); });