refactor(frontend): every stored key declares its kind, and preferences follow the account
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.
This commit is contained in:
@@ -17,10 +17,7 @@ const { store, savePrefs } = vi.hoisted(() => ({
|
||||
// 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")>()),
|
||||
readAccount: (base: string, fallback: unknown) => {
|
||||
const raw = store.get(base);
|
||||
return raw == null ? fallback : JSON.parse(raw);
|
||||
},
|
||||
getAccountRaw: (base: string) => store.get(base) ?? null,
|
||||
writeAccount: (base: string, value: unknown) => {
|
||||
store.set(base, JSON.stringify(value));
|
||||
},
|
||||
@@ -77,6 +74,52 @@ describe("usePref", () => {
|
||||
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" });
|
||||
|
||||
Reference in New Issue
Block a user