fix(plex): round-2 deep-review follow-ups on the review fixes
Second review round, scoped to the round-1 fix commit (where round-1's own fixes
introduced new code):
- The collection chip-remove pruned collectionTitles to an empty {} on last removal,
which still hashes differently from the pristine no-collection state (key absent) in
the React Query keys → a residual cache split. Collapse an empty map to undefined so
the key-shape matches the pristine one.
- Replace the tautological "order-independent signature" test (both objects spread from
EMPTY_PLEX_FILTERS inherited the same key order, so it proved nothing) with full
literals in genuinely different insertion order that would fail an order-sensitive
JSON.stringify signature.
- Cover the collectionTitles chip-remove prune (usePlexActiveFilters.dom.test.tsx) and
the plexSurprise/plexDiscover lang-key split (queryKeys.test.ts) with regression tests.
Gate: tsc/lint/knip/prettier clean, 412 vitest.
This commit is contained in:
@@ -18,7 +18,12 @@ const plexState = {
|
||||
show: "watched",
|
||||
// A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip).
|
||||
sort: { key: "year", dir: "asc" },
|
||||
filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"] },
|
||||
filters: {
|
||||
...EMPTY_PLEX_FILTERS,
|
||||
genres: ["Action"],
|
||||
collections: ["A"],
|
||||
collectionTitles: { A: "Marvel" },
|
||||
},
|
||||
};
|
||||
const setShow = vi.fn();
|
||||
const setFilters = vi.fn();
|
||||
@@ -40,4 +45,21 @@ describe("usePlexActiveFilters", () => {
|
||||
expect(keys).not.toContain("sort");
|
||||
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => {
|
||||
setFilters.mockClear();
|
||||
const { result } = renderHook(() => usePlexActiveFilters());
|
||||
const chip = result.current.find((c) => c.key === "collection:A");
|
||||
expect(chip).toBeDefined();
|
||||
chip!.remove();
|
||||
// patch() merges into the full filters object; assert the two touched fields directly. No residual
|
||||
// { A: "Marvel" } and no empty {} (which would still split the query-key cache from the pristine
|
||||
// no-collection state) — collectionTitles must be undefined.
|
||||
const arg = setFilters.mock.calls[0]![0] as {
|
||||
collections: string[];
|
||||
collectionTitles?: unknown;
|
||||
};
|
||||
expect(arg.collections).toEqual([]);
|
||||
expect(arg.collectionTitles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,11 +123,13 @@ export function buildPlexActiveFilters(
|
||||
label: filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
|
||||
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
|
||||
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
|
||||
// one (an empty `{}` still hashes differently from an absent key).
|
||||
remove: () => {
|
||||
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
|
||||
patch({
|
||||
collections: filters.collections.filter((x) => x !== c),
|
||||
collectionTitles: restTitles,
|
||||
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,11 +44,29 @@ describe("plexFiltersSignature", () => {
|
||||
collections: ["A"],
|
||||
});
|
||||
|
||||
it("is independent of key order for equivalent filters", () => {
|
||||
// Same field values, keys assigned in a different literal order — appendPlexFilters emits by field,
|
||||
// not by object key order, so the signatures must match.
|
||||
const a: PlexFilters = { ...EMPTY_PLEX_FILTERS, genres: ["Drama"], collections: ["A"] };
|
||||
const b: PlexFilters = { ...EMPTY_PLEX_FILTERS, collections: ["A"], genres: ["Drama"] };
|
||||
it("is independent of object key insertion order", () => {
|
||||
// Full literals with genuinely different key INSERTION order (NOT spread from a shared base, which
|
||||
// would inherit that base's order and make this tautological). appendPlexFilters reads by field, so
|
||||
// the signatures must match — this test FAILS against an order-sensitive JSON.stringify signature.
|
||||
const a: PlexFilters = {
|
||||
genres: ["Drama"],
|
||||
contentRatings: [],
|
||||
directors: [],
|
||||
actors: [],
|
||||
studios: [],
|
||||
keywords: [],
|
||||
collections: ["A"],
|
||||
};
|
||||
const b: PlexFilters = {
|
||||
collections: ["A"],
|
||||
keywords: [],
|
||||
studios: [],
|
||||
actors: [],
|
||||
directors: [],
|
||||
contentRatings: [],
|
||||
genres: ["Drama"],
|
||||
};
|
||||
expect(JSON.stringify(Object.keys(a))).not.toBe(JSON.stringify(Object.keys(b))); // orders truly differ
|
||||
expect(plexFiltersSignature(a)).toBe(plexFiltersSignature(b));
|
||||
});
|
||||
|
||||
|
||||
@@ -84,4 +84,16 @@ describe("qk — variadic composite plex keys", () => {
|
||||
{ genres: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keys the Discover/Surprise rails by lang so a language switch caches separately", () => {
|
||||
// Both surfaces filter against the language-specific genre column, so HU and EN must not collide.
|
||||
expect(qk.plexDiscover("movie", "hu", FILTERS)).not.toEqual(
|
||||
qk.plexDiscover("movie", "en", FILTERS),
|
||||
);
|
||||
expect(qk.plexSurprise("movie", "hu", FILTERS)).not.toEqual(
|
||||
qk.plexSurprise("movie", "en", FILTERS),
|
||||
);
|
||||
// A bare call stays a prefix for the reshuffle/watched-toggle invalidations.
|
||||
expect(qk.plexSurprise()).toEqual(["plex-surprise"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user