import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useSplitRatio } from "./use-split-ratio"; const STORAGE_KEY = "test:split-ratio"; afterEach(() => { window.localStorage.clear(); vi.restoreAllMocks(); }); describe("useSplitRatio", () => { describe("initialisation", () => { it("returns the initial value when nothing is stored", () => { const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); expect(result.current.ratio).toBe(0.5); }); it("returns the stored value on a re-render after mount", () => { window.localStorage.setItem(STORAGE_KEY, "0.33"); const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); expect(result.current.ratio).toBeCloseTo(0.33, 5); }); it("clamps a stored value that fell outside the new bounds", () => { window.localStorage.setItem(STORAGE_KEY, "0.05"); // below min const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5, min: 0.2, max: 0.8, }), ); expect(result.current.ratio).toBe(0.2); }); it("ignores an unparseable stored value and falls back to initial", () => { window.localStorage.setItem(STORAGE_KEY, "not-a-number"); const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); expect(result.current.ratio).toBe(0.5); }); it("ignores an empty stored value and falls back to initial", () => { window.localStorage.setItem(STORAGE_KEY, ""); const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); expect(result.current.ratio).toBe(0.5); }); }); describe("setRatio", () => { it("updates the ratio and persists to localStorage", () => { const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); act(() => result.current.setRatio(0.6)); expect(result.current.ratio).toBe(0.6); expect(window.localStorage.getItem(STORAGE_KEY)).toBe("0.6"); }); it("clamps values below the min before persisting", () => { const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5, min: 0.2, max: 0.8, }), ); act(() => result.current.setRatio(0.01)); expect(result.current.ratio).toBe(0.2); expect(window.localStorage.getItem(STORAGE_KEY)).toBe("0.2"); }); it("clamps values above the max before persisting", () => { const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5, min: 0.2, max: 0.8, }), ); act(() => result.current.setRatio(0.99)); expect(result.current.ratio).toBe(0.8); expect(window.localStorage.getItem(STORAGE_KEY)).toBe("0.8"); }); it("uses default bounds (0.15, 0.85) when no min/max are passed", () => { const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); act(() => result.current.setRatio(0.05)); expect(result.current.ratio).toBe(0.15); act(() => result.current.setRatio(0.95)); expect(result.current.ratio).toBe(0.85); }); it("survives a localStorage write failure (quota / Safari private mode)", () => { // Stub the prototype method so the failure path runs even when // the instance-level setItem isn't spy-able in jsdom. The // contract is: the in-memory state still updates, and the // failure doesn't break the UI. const protoSpy = vi .spyOn(Storage.prototype, "setItem") .mockImplementation(() => { throw new Error("QuotaExceededError"); }); const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); expect(() => { act(() => result.current.setRatio(0.7)); }).not.toThrow(); expect(result.current.ratio).toBe(0.7); // The setter did attempt the write — confirm the spy saw it. expect(protoSpy).toHaveBeenCalled(); protoSpy.mockRestore(); }); }); describe("storage key isolation", () => { it("two hooks with different keys don't clobber each other", () => { window.localStorage.setItem("key-a", "0.25"); window.localStorage.setItem("key-b", "0.75"); const { result: a } = renderHook(() => useSplitRatio({ storageKey: "key-a", initial: 0.5 }), ); const { result: b } = renderHook(() => useSplitRatio({ storageKey: "key-b", initial: 0.5 }), ); expect(a.current.ratio).toBeCloseTo(0.25, 5); expect(b.current.ratio).toBeCloseTo(0.75, 5); act(() => a.current.setRatio(0.3)); expect(b.current.ratio).toBeCloseTo(0.75, 5); // untouched }); }); describe("lazy initialisation", () => { it("a fresh mount picks up the latest stored value via the useState initialiser", () => { const { result } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); // Simulate another tab writing a new value while this hook was idle. window.localStorage.setItem(STORAGE_KEY, "0.61"); const { result: remounted } = renderHook(() => useSplitRatio({ storageKey: STORAGE_KEY, initial: 0.5 }), ); // The original hook is unaware (no cross-instance sync required // — single-tab contract). A fresh mount, however, reads the // current localStorage value via the useState initialiser, so // it lands on the latest value with no flicker. expect(result.current.ratio).toBe(0.5); // unchanged — first hook expect(remounted.current.ratio).toBeCloseTo(0.61, 5); }); }); });