storage.ts's registry gives each key a kind (pref, device, view, cache, public); a key without one does not compile. A contract test refuses a key spelled out elsewhere, an account preference read or written straight from storage instead of through usePref, and an e2e seed of a key nobody reads. The keys the next account's wipe keeps are the public ones. The seven preferences kept only in the browser move onto usePref: start on Home, the onboarding dismissal, the release banner's last version, the channel manager's layout, the Plex playlist layout, the playlist rail's sort and channel discovery's sort. usePref reads an older bare-string copy, and a value only this browser held is saved to the server once, so the account's other devices get it. Performance mode is the device's: it is neither read from the account nor saved to it.
235 lines
9.0 KiB
TypeScript
235 lines
9.0 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, 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<string, string>(),
|
|
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<typeof import("./storage")>()),
|
|
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<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");
|
|
});
|
|
|
|
// 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<string>("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<string>("view", "cards", { localKey: "siftlode.feedView" });
|
|
renderHook(useViewPref, { wrapper: wrapperFor(qc) });
|
|
expect(savePrefs).toHaveBeenCalledExactlyOnceWith({ view: "tiles" });
|
|
expect(qc.getQueryData<{ preferences: Record<string, unknown> }>(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<string>("view", "cards", { localKey: "siftlode.feedView" }), {
|
|
wrapper: wrapperFor(qc),
|
|
});
|
|
store.clear();
|
|
renderHook(() => usePref<string>("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<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" });
|
|
});
|
|
|
|
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"] });
|
|
});
|
|
});
|