// @vitest-environment jsdom // // usePref: the one per-account + server preference hook. Covers the three seed sources (server cache / // localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror), // debounced writes, parse, and the raw useSavePref persister. The `me` cache is the // server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement, type ReactNode } from "react"; const { store, savePrefs } = vi.hoisted(() => ({ store: new Map(), savePrefs: vi.fn(() => Promise.resolve({})), })); // Partial: i18n (which usePref now imports, to title a refused save) reads its key from `LS`. vi.mock("./storage", async (importOriginal) => ({ ...(await importOriginal()), getAccountRaw: (base: string) => store.get(base) ?? null, writeAccount: (base: string, value: unknown) => { store.set(base, JSON.stringify(value)); }, })); vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } })); // The failure path (usePref.saveFailure.dom.test.ts) is not this file's subject; stubbed so the // reporter's notifications → storage chain does not meet the storage mock above. vi.mock("./actionErrors", () => ({ notifyActionError: vi.fn() })); import { usePref, useSavePref } from "./usePref"; import { qk } from "./queryKeys"; function makeClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }); } function seedMe(qc: QueryClient, preferences: Record, id = 1) { qc.setQueryData(qk.me(), { id, preferences }); } function wrapperFor(qc: QueryClient) { return ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: qc }, children); } describe("usePref", () => { beforeEach(() => { store.clear(); savePrefs.mockClear(); }); it("seeds from the default when neither server nor mirror has the key", () => { const qc = makeClient(); const { result } = renderHook(() => usePref("view", "cards"), { wrapper: wrapperFor(qc), }); expect(result.current[0]).toBe("cards"); }); it("seeds from the server (me cache) when present", () => { const qc = makeClient(); seedMe(qc, { view: "rows" }); const { result } = renderHook(() => usePref("view", "cards"), { wrapper: wrapperFor(qc), }); expect(result.current[0]).toBe("rows"); }); it("seeds from the localStorage mirror when there is no server value", () => { const qc = makeClient(); store.set("siftlode.feedView", JSON.stringify("tiles")); const { result } = renderHook( () => usePref("view", "cards", { localKey: "siftlode.feedView" }), { wrapper: wrapperFor(qc) }, ); expect(result.current[0]).toBe("tiles"); }); // A copy written before usePref held the key is a bare string ("cards", "1"), not JSON. it("reads a bare-string copy as that string, through parse", () => { const qc = makeClient(); store.set("siftlode.plexPlaylistLayout", "tree"); const { result } = renderHook( () => usePref("plexPlaylistLayout", "accordion", { localKey: "siftlode.plexPlaylistLayout", parse: (raw) => (raw === "tree" ? "tree" : "accordion"), }), { wrapper: wrapperFor(qc) }, ); expect(result.current[0]).toBe("tree"); }); // A value only this browser held reaches the server, so the account's other devices get it: once, // and only while the server has none. it("saves a value only this browser held to the server, once", () => { const qc = makeClient(); seedMe(qc, {}); store.set("siftlode.feedView", JSON.stringify("tiles")); const useViewPref = () => usePref("view", "cards", { localKey: "siftlode.feedView" }); renderHook(useViewPref, { wrapper: wrapperFor(qc) }); expect(savePrefs).toHaveBeenCalledExactlyOnceWith({ view: "tiles" }); expect(qc.getQueryData<{ preferences: Record }>(qk.me())?.preferences).toEqual( { view: "tiles" }, ); renderHook(useViewPref, { wrapper: wrapperFor(qc) }); // a second instance: the server has it now expect(savePrefs).toHaveBeenCalledOnce(); }); it("sends nothing at mount when the server has the value, or nobody has one", () => { const qc = makeClient(); seedMe(qc, { view: "rows" }); store.set("siftlode.feedView", JSON.stringify("tiles")); renderHook(() => usePref("view", "cards", { localKey: "siftlode.feedView" }), { wrapper: wrapperFor(qc), }); store.clear(); renderHook(() => usePref("other", "x", { localKey: "siftlode.feedView" }), { wrapper: wrapperFor(qc), }); expect(savePrefs).not.toHaveBeenCalled(); }); it("prefers the server value over the mirror", () => { const qc = makeClient(); seedMe(qc, { view: "rows" }); store.set("siftlode.feedView", JSON.stringify("tiles")); const { result } = renderHook( () => usePref("view", "cards", { localKey: "siftlode.feedView" }), { wrapper: wrapperFor(qc) }, ); expect(result.current[0]).toBe("rows"); }); it("applies parse to a raw stored value", () => { const qc = makeClient(); seedMe(qc, { view: "GRID" }); const { result } = renderHook( () => usePref("view", "cards", { parse: (r) => String(r).toLowerCase() }), { wrapper: wrapperFor(qc) }, ); expect(result.current[0]).toBe("grid"); }); it("on set: updates state, writes the server, merges the me cache, and the mirror", () => { const qc = makeClient(); seedMe(qc, {}); const { result } = renderHook( () => usePref("view", "cards", { localKey: "siftlode.feedView" }), { wrapper: wrapperFor(qc) }, ); act(() => result.current[1]("rows")); expect(result.current[0]).toBe("rows"); expect(savePrefs).toHaveBeenCalledWith({ view: "rows" }); expect( (qc.getQueryData(qk.me()) as { preferences: Record }).preferences.view, ).toBe("rows"); expect(store.get("siftlode.feedView")).toBe(JSON.stringify("rows")); }); it("does not write a localStorage mirror when no localKey is given", () => { const qc = makeClient(); seedMe(qc, {}); const { result } = renderHook(() => usePref("playerLoop", "off"), { wrapper: wrapperFor(qc), }); act(() => result.current[1]("all")); expect(store.size).toBe(0); expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" }); }); describe("debounceMs", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it("coalesces rapid sets into one PUT after the delay", () => { const qc = makeClient(); seedMe(qc, {}); const { result } = renderHook(() => usePref("vol", 0, { debounceMs: 300 }), { wrapper: wrapperFor(qc), }); act(() => { result.current[1](10); result.current[1](20); result.current[1](30); }); // Local state is instant; the server write is deferred. expect(result.current[0]).toBe(30); expect(savePrefs).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(300); }); expect(savePrefs).toHaveBeenCalledTimes(1); expect(savePrefs).toHaveBeenCalledWith({ vol: 30 }); }); it("flushes a pending debounced write on unmount instead of dropping it", () => { const qc = makeClient(); seedMe(qc, {}); const { result, unmount } = renderHook(() => usePref("vol", 0, { debounceMs: 300 }), { wrapper: wrapperFor(qc), }); act(() => result.current[1](42)); expect(savePrefs).not.toHaveBeenCalled(); // still inside the debounce window unmount(); // cleanup must flush the pending write, not lose it expect(savePrefs).toHaveBeenCalledTimes(1); expect(savePrefs).toHaveBeenCalledWith({ vol: 42 }); }); it("unmount with no pending write does not call savePrefs", () => { const qc = makeClient(); seedMe(qc, {}); const { unmount } = renderHook(() => usePref("vol", 0, { debounceMs: 300 }), { wrapper: wrapperFor(qc), }); unmount(); expect(savePrefs).not.toHaveBeenCalled(); }); }); }); describe("useSavePref", () => { beforeEach(() => savePrefs.mockClear()); it("persists an arbitrary patch and merges it into the me cache", () => { const qc = makeClient(); seedMe(qc, { a: 1 }); const { result } = renderHook(() => useSavePref(), { wrapper: wrapperFor(qc) }); act(() => result.current({ plexHiddenStripSources: ["x"] })); expect(savePrefs).toHaveBeenCalledWith({ plexHiddenStripSources: ["x"] }); const prefs = (qc.getQueryData(qk.me()) as { preferences: Record }) .preferences; expect(prefs).toEqual({ a: 1, plexHiddenStripSources: ["x"] }); }); });