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:
@@ -1,4 +1,4 @@
|
||||
import { type SortOption, type SortState } from "./columnSort";
|
||||
import { healSort, type SortOption, type SortState } from "./columnSort";
|
||||
|
||||
// Plex's sort vocabulary, source-agnostic like the feed's — a `SortOption[]` fed to the shared
|
||||
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
|
||||
@@ -16,9 +16,20 @@ function plexSortKeys(scope: string): string[] {
|
||||
export const PLEX_SORT_FALLBACK: SortState = { key: "title", dir: "asc" };
|
||||
|
||||
/** Labelled, alpha-ordered options for the SortControl. `label` resolves each key (i18n
|
||||
* `plex.sort.<key>`); pass a no-op label when only the key set matters (healing). */
|
||||
* `plex.sort.<key>`). */
|
||||
export function plexSortOptions(scope: string, label: (key: string) => string): SortOption[] {
|
||||
return plexSortKeys(scope)
|
||||
.map((key) => ({ key, label: label(key) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/** Heal a stored sort against a scope's available keys, collapsing an out-of-scope key to the default.
|
||||
* The single heal both the sidebar control and the browse query use, so they never diverge. Always
|
||||
* non-null (the fallback is), so callers can read `.key` without a guard. */
|
||||
export function healPlexSort(sort: SortState, scope: string): NonNullable<SortState> {
|
||||
return healSort(
|
||||
sort,
|
||||
plexSortKeys(scope).map((key) => ({ key, label: key })),
|
||||
PLEX_SORT_FALLBACK,
|
||||
)!;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user