The remaining E4 deep-review deferred gaps, in one sprint: - readSelectedId split into playlistsInit.ts (react-refresh-clean, testable) — ?pl accepted only as a positive integer; empty/0/negative/fractional/non-numeric fall back to the stored selection; null when neither is present. - useUrlMirror: mounts write the param, a same-serialized rerender does NOT re-fire (stable dep), a null value clears the key, other params preserved. - usePref: a pending debounced write is FLUSHED on unmount (not dropped); an unmount with nothing pending stays silent. 20 new test cases; full FE gate green (tsc/eslint/prettier/knip, 309 vitest).
198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
// @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, adopt-on-login, 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, waitFor } 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<string, string>(),
|
|
savePrefs: vi.fn(() => Promise.resolve({})),
|
|
}));
|
|
|
|
vi.mock("./storage", () => ({
|
|
readAccount: (base: string, fallback: unknown) => {
|
|
const raw = store.get(base);
|
|
return raw == null ? fallback : JSON.parse(raw);
|
|
},
|
|
writeAccount: (base: string, value: unknown) => {
|
|
store.set(base, JSON.stringify(value));
|
|
},
|
|
}));
|
|
vi.mock("./api", () => ({ api: { savePrefs, me: 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<string, unknown>, 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<string>("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<string>("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<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
|
{ wrapper: wrapperFor(qc) },
|
|
);
|
|
expect(result.current[0]).toBe("tiles");
|
|
});
|
|
|
|
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<string>("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<string>("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<string>("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<string, unknown> }).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<string>("playerLoop", "off"), {
|
|
wrapper: wrapperFor(qc),
|
|
});
|
|
act(() => result.current[1]("all"));
|
|
expect(store.size).toBe(0);
|
|
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
|
|
});
|
|
|
|
it("adopts the server value when the account id changes (login / switch)", async () => {
|
|
const qc = makeClient();
|
|
seedMe(qc, { view: "cards" }, 1);
|
|
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
|
wrapper: wrapperFor(qc),
|
|
});
|
|
expect(result.current[0]).toBe("cards");
|
|
// A different account resolves. The passive me observer notifies asynchronously (react-query's
|
|
// notifyManager batches), so wait for the re-render — the same path the real App drives on login.
|
|
act(() => seedMe(qc, { view: "tiles" }, 2));
|
|
await waitFor(() => expect(result.current[0]).toBe("tiles"));
|
|
});
|
|
|
|
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<number>("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<number>("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<number>("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<string, unknown> })
|
|
.preferences;
|
|
expect(prefs).toEqual({ a: 1, plexHiddenStripSources: ["x"] });
|
|
});
|
|
});
|