// @vitest-environment jsdom import { act, createElement } from "react"; import type { Root } from "react-dom/client"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DualSurfaceLayoutMode, DualSurfaceOrientation } from "../lib/dual-surface-pref"; import { DUAL_SURFACE_LAYOUT_MODE_KEY, DUAL_SURFACE_ORIENTATION_KEY, DUAL_SURFACE_SPLIT_RATIO_KEY, readDualSurfacePreferences, } from "../lib/dual-surface-pref"; import type { DualSurfaceLayoutState } from "./useDualSurfaceLayout"; import { useDualSurfaceLayout } from "./useDualSurfaceLayout"; describe("useDualSurfaceLayout", () => { let container: HTMLDivElement; let root: Root; let mockStore: Map; let mediaListeners: Set<(e: MediaQueryListEvent) => void>; let isDesktopMatch: boolean; let latestState!: DualSurfaceLayoutState; function TestHarness() { latestState = useDualSurfaceLayout(); return null; } beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); mockStore = new Map(); mediaListeners = new Set(); isDesktopMatch = true; // Mock localStorage const storageMock = { getItem: vi.fn((key: string) => mockStore.get(key) ?? null), setItem: vi.fn((key: string, value: string) => { mockStore.set(key, value); }), removeItem: vi.fn((key: string) => { mockStore.delete(key); }), clear: vi.fn(() => { mockStore.clear(); }), }; // Mock matchMedia const matchMediaMock = vi.fn().mockImplementation((query: string) => ({ matches: isDesktopMatch, media: query, onchange: null, addListener: vi.fn(), // legacy removeListener: vi.fn(), // legacy addEventListener: vi.fn((event: string, callback: (e: MediaQueryListEvent) => void) => { if (event === "change") mediaListeners.add(callback); }), removeEventListener: vi.fn((event: string, callback: (e: MediaQueryListEvent) => void) => { if (event === "change") mediaListeners.delete(callback); }), dispatchEvent: vi.fn(), })); vi.stubGlobal("window", { localStorage: storageMock, matchMedia: matchMediaMock, }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); async function renderHook(): Promise { await act(async () => { root.render(createElement(TestHarness)); }); return latestState; } function simulateViewportResize(desktop: boolean) { isDesktopMatch = desktop; const event = { matches: desktop } as MediaQueryListEvent; act(() => { for (const listener of mediaListeners) { listener(event); } }); } describe("Tier 1 - Feature Coverage: Initial State & Preferences Sync", () => { it("initializes with default layout values on desktop when storage is empty", async () => { await renderHook(); const mode: DualSurfaceLayoutMode = latestState.layoutMode; const orientation: DualSurfaceOrientation = latestState.orientation; expect(latestState.splitRatio).toBe(0.5); expect(mode).toBe("split"); expect(orientation).toBe("stream-first"); expect(latestState.isMobile).toBe(false); }); it("initializes from pre-existing stored preferences", async () => { mockStore.set(DUAL_SURFACE_SPLIT_RATIO_KEY, "0.65"); mockStore.set(DUAL_SURFACE_LAYOUT_MODE_KEY, "canvas"); mockStore.set(DUAL_SURFACE_ORIENTATION_KEY, "canvas-first"); await renderHook(); expect(latestState.splitRatio).toBe(0.65); expect(latestState.layoutMode).toBe("canvas"); expect(latestState.orientation).toBe("canvas-first"); expect(latestState.isMobile).toBe(false); }); it("updates splitRatio and synchronizes to preferences", async () => { await renderHook(); await act(async () => { latestState.setSplitRatio(0.35); }); expect(latestState.splitRatio).toBe(0.35); expect(mockStore.get(DUAL_SURFACE_SPLIT_RATIO_KEY)).toBe("0.35"); expect(readDualSurfacePreferences().splitRatio).toBe(0.35); }); it("clamps splitRatio on setSplitRatio within [0.20, 0.80]", async () => { await renderHook(); await act(async () => { latestState.setSplitRatio(0.12); }); expect(latestState.splitRatio).toBe(0.2); expect(readDualSurfacePreferences().splitRatio).toBe(0.2); await act(async () => { latestState.setSplitRatio(0.95); }); expect(latestState.splitRatio).toBe(0.8); expect(readDualSurfacePreferences().splitRatio).toBe(0.8); }); it("switches to Focus Stream mode and persists layoutMode", async () => { await renderHook(); await act(async () => { latestState.setLayoutMode("stream"); }); expect(latestState.layoutMode).toBe("stream"); expect(mockStore.get(DUAL_SURFACE_LAYOUT_MODE_KEY)).toBe("stream"); expect(readDualSurfacePreferences().layoutMode).toBe("stream"); }); it("switches to Focus Canvas mode and persists layoutMode", async () => { await renderHook(); await act(async () => { latestState.setLayoutMode("canvas"); }); expect(latestState.layoutMode).toBe("canvas"); expect(mockStore.get(DUAL_SURFACE_LAYOUT_MODE_KEY)).toBe("canvas"); expect(readDualSurfacePreferences().layoutMode).toBe("canvas"); }); it("switches back to Dual Split mode and persists layoutMode", async () => { await renderHook(); await act(async () => { latestState.setLayoutMode("canvas"); }); expect(latestState.layoutMode).toBe("canvas"); await act(async () => { latestState.setLayoutMode("split"); }); expect(latestState.layoutMode).toBe("split"); expect(mockStore.get(DUAL_SURFACE_LAYOUT_MODE_KEY)).toBe("split"); }); it("toggles orientation back and forth and persists", async () => { await renderHook(); expect(latestState.orientation).toBe("stream-first"); await act(async () => { latestState.toggleOrientation(); }); expect(latestState.orientation).toBe("canvas-first"); expect(mockStore.get(DUAL_SURFACE_ORIENTATION_KEY)).toBe("canvas-first"); await act(async () => { latestState.toggleOrientation(); }); expect(latestState.orientation).toBe("stream-first"); expect(mockStore.get(DUAL_SURFACE_ORIENTATION_KEY)).toBe("stream-first"); }); it("explicitly sets orientation via setOrientation", async () => { await renderHook(); await act(async () => { latestState.setOrientation("canvas-first"); }); expect(latestState.orientation).toBe("canvas-first"); await act(async () => { latestState.setOrientation("stream-first"); }); expect(latestState.orientation).toBe("stream-first"); }); it("snaps splitRatio back to 0.50 via resetSplit", async () => { await renderHook(); await act(async () => { latestState.setSplitRatio(0.72); }); expect(latestState.splitRatio).toBe(0.72); await act(async () => { latestState.resetSplit(); }); expect(latestState.splitRatio).toBe(0.5); expect(readDualSurfacePreferences().splitRatio).toBe(0.5); }); }); describe("Tier 2 - Boundary Cases & Responsive Degradation", () => { it("detects mobile viewport on initial mount when media query does not match desktop", async () => { isDesktopMatch = false; // < 768px await renderHook(); expect(latestState.isMobile).toBe(true); }); it("dynamically updates isMobile when viewport resizes across the 768px breakpoint", async () => { await renderHook(); expect(latestState.isMobile).toBe(false); simulateViewportResize(false); // resize to mobile expect(latestState.isMobile).toBe(true); simulateViewportResize(true); // resize back to desktop expect(latestState.isMobile).toBe(false); }); it("preserves desktop splitRatio untouched when transitioning to mobile", async () => { await renderHook(); await act(async () => { latestState.setSplitRatio(0.38); }); expect(latestState.splitRatio).toBe(0.38); // Viewport shrinks to mobile (< md) simulateViewportResize(false); expect(latestState.isMobile).toBe(true); // Desktop splitRatio in state and storage must remain intact expect(latestState.splitRatio).toBe(0.38); expect(readDualSurfacePreferences().splitRatio).toBe(0.38); // Viewport expands back to desktop simulateViewportResize(true); expect(latestState.isMobile).toBe(false); expect(latestState.splitRatio).toBe(0.38); }); it("cleans up media query event listeners on unmount", async () => { await renderHook(); expect(mediaListeners.size).toBeGreaterThan(0); await act(async () => { root.unmount(); }); expect(mediaListeners.size).toBe(0); }); it("is idempotent when setting the current layoutMode again", async () => { await renderHook(); expect(latestState.layoutMode).toBe("split"); await act(async () => { latestState.setLayoutMode("split"); }); expect(latestState.layoutMode).toBe("split"); }); it("is idempotent when setting the current orientation again", async () => { await renderHook(); expect(latestState.orientation).toBe("stream-first"); await act(async () => { latestState.setOrientation("stream-first"); }); expect(latestState.orientation).toBe("stream-first"); }); it("calling resetSplit when already at 0.50 maintains 0.50 cleanly", async () => { await renderHook(); expect(latestState.splitRatio).toBe(0.5); await act(async () => { latestState.resetSplit(); }); expect(latestState.splitRatio).toBe(0.5); }); }); describe("Tier 3 - Cross-Feature Combinations & Workflow Resilience", () => { it("preserves custom splitRatio when switching between preset modes and back to split", async () => { await renderHook(); // User customizes split ratio to 0.35 await act(async () => { latestState.setSplitRatio(0.35); }); expect(latestState.splitRatio).toBe(0.35); // User switches to Focus Stream (100/0) await act(async () => { latestState.setLayoutMode("stream"); }); expect(latestState.layoutMode).toBe("stream"); // User switches to Focus Canvas (0/100) await act(async () => { latestState.setLayoutMode("canvas"); }); expect(latestState.layoutMode).toBe("canvas"); // User switches back to Dual Split await act(async () => { latestState.setLayoutMode("split"); }); expect(latestState.layoutMode).toBe("split"); // The 0.35 ratio must be preserved expect(latestState.splitRatio).toBe(0.35); }); it("restores customized ratio and orientation upon unmount and remount (page reload simulation)", async () => { await renderHook(); await act(async () => { latestState.setSplitRatio(0.68); latestState.setOrientation("canvas-first"); latestState.setLayoutMode("canvas"); }); // Unmount hook await act(async () => { root.unmount(); }); // Re-mount hook (simulating page reload with persisted storage) root = createRoot(container); await renderHook(); expect(latestState.splitRatio).toBe(0.68); expect(latestState.orientation).toBe("canvas-first"); expect(latestState.layoutMode).toBe("canvas"); }); it("handles rapid consecutive state changes deterministically", async () => { await renderHook(); await act(async () => { latestState.setSplitRatio(0.3); latestState.toggleOrientation(); latestState.setLayoutMode("canvas"); latestState.setSplitRatio(0.7); latestState.setLayoutMode("split"); }); expect(latestState.splitRatio).toBe(0.7); expect(latestState.orientation).toBe("canvas-first"); expect(latestState.layoutMode).toBe("split"); expect(readDualSurfacePreferences()).toEqual({ splitRatio: 0.7, orientation: "canvas-first", layoutMode: "split", }); }); }); });