fix(sort): address R13 S1 review findings

- PlexSidebar activeCount now compares the HEALED sort, so an out-of-scope key
  (movie "duration" after switching to shows) no longer inflates the
  active-filter badge while the control + grid show the default.
- PlexProvider strips the legacy `sortDir` from the filters blob, so it stops
  leaking into the facets query key and being re-persisted on every change.
- PlexProvider sort migration guards the fall-through: valid JSON that isn't a
  SortState now falls back instead of using the raw JSON string as the key.
- New shared healPlexSort(sort, scope) is the single heal both PlexBrowse and
  PlexSidebar use (removes the noop-label plexSortOptions call + the duplication).
- Tests: useSortState.dom.test.ts (hydrate/persist/heal round-trip via renderHook)
  and SortControl.dom.test.tsx (options, pick, direction toggle, directionless,
  trailing). vitest 94 -> 105.

Gate green: tsc/eslint(0 err)/prettier/knip clean, vitest 105.
This commit is contained in:
2026-08-14 00:00:31 +02:00
parent 45bd588af7
commit 02c8c71c53
6 changed files with 164 additions and 15 deletions
+56
View File
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
//
// The persistence/hydration wiring of useSortState (the pure hydrateSort/healSort logic it leans on is
// covered in columnSort.test.ts). storage is mocked with an in-memory map so the hook can be exercised
// without a logged-in account (getAccountRaw/setAccountRaw are account-keyed in the real module).
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
store.set(k, v);
},
}));
import { useSortState } from "./useSortState";
describe("useSortState", () => {
beforeEach(() => store.clear());
it("hydrates to the fallback when storage is empty", () => {
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toBeNull();
const withFallback = renderHook(() => useSortState("k2", [], { key: "date", dir: "desc" }));
expect(withFallback.result.current[0]).toEqual({ key: "date", dir: "desc" });
});
it("hydrates a stored SortState on mount", () => {
store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
});
it("persists on set, and a fresh mount reads it back (write → reload → hydrate)", () => {
const first = renderHook(() => useSortState("k"));
act(() => first.result.current[1]({ key: "name", dir: "asc" }));
expect(first.result.current[0]).toEqual({ key: "name", dir: "asc" });
expect(store.get("k")).toBe(JSON.stringify({ key: "name", dir: "asc" }));
const second = renderHook(() => useSortState("k"));
expect(second.result.current[0]).toEqual({ key: "name", dir: "asc" });
});
it("heals a stored key that the supplied options no longer offer", () => {
store.set("k", JSON.stringify({ key: "gone", dir: "asc" }));
const opts = [{ key: "name", label: "Name" }];
const { result } = renderHook(() => useSortState("k", opts, { key: "name", dir: "asc" }));
expect(result.current[0]).toEqual({ key: "name", dir: "asc" });
});
it("keeps a stored key un-healed when no options are supplied (caller heals for display)", () => {
store.set("k", JSON.stringify({ key: "anything", dir: "desc" }));
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toEqual({ key: "anything", dir: "desc" });
});
});