Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus smaller items; all fixed with tests. - watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY local row (a change whose immediate push failed) — that stranded the value forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push. - PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab persisted mount-time DEFAULTS over the account's stored scope/sort/filters (base read before the account was pinned). Persist/keep only URL-present dims; re-hydrate the omitted ones from storage. - playback resume write-back: guard against out-of-order delivery (a superseded older push now drops) and evict the throttle map so it can't grow unbounded. - PlexPlayer: announce mid-playback buffering to assistive tech (role=status + sr-only label); new plex.player.buffering (HU+EN). - columnSort: hoist the shared key:dir sort codec used by both URL-state modules. - Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber + refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
133 lines
6.1 KiB
TypeScript
133 lines
6.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
|
|
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
|
// re-routed through usePersistedTab in R13 S2b, so it's exercised here rather than only by hand E2E.
|
|
//
|
|
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
|
|
// usePersistedTab / useSortState / useFilterState all resolve their account-scoped keys; only
|
|
// NavigationProvider (the sole runtime dependency) is mocked, pinned to the channels page.
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
|
|
vi.mock("./NavigationProvider", () => ({
|
|
useNavigation: () => ({ page: "channels" }),
|
|
useNavigationActions: () => ({ setPage: vi.fn() }),
|
|
}));
|
|
|
|
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
|
|
import { setActiveAccount } from "../lib/api/core";
|
|
|
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
<ChannelsProvider>{children}</ChannelsProvider>
|
|
);
|
|
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
|
const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id)
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
|
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
|
// and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
|
// this provider's mount-time URL seed and change the starting tab.
|
|
window.history.replaceState({ sfPage: "channels" }, "", "/");
|
|
});
|
|
|
|
describe("ChannelsProvider tab history", () => {
|
|
it("setView pushes a _chTab history entry and persists the tab", () => {
|
|
const { result } = render();
|
|
expect(result.current.view).toBe("subscribed"); // default
|
|
|
|
act(() => result.current.setView("hidden"));
|
|
|
|
expect(result.current.view).toBe("hidden");
|
|
expect(window.history.state).toMatchObject({ sfPage: "channels", _chTab: "hidden" });
|
|
expect(localStorage.getItem(VIEW_KEY)).toBe("hidden");
|
|
});
|
|
|
|
it("does NOT push a new entry when re-selecting the current tab", () => {
|
|
const { result } = render();
|
|
const spy = vi.spyOn(window.history, "pushState");
|
|
act(() => result.current.setView("subscribed")); // already the current tab
|
|
expect(spy).not.toHaveBeenCalled();
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("a genuine Back (popstate carrying _chTab) restores and re-persists that tab", () => {
|
|
const { result } = render();
|
|
act(() => result.current.setView("blocked"));
|
|
expect(result.current.view).toBe("blocked");
|
|
|
|
// Browser Back to an entry stamped with a different tab.
|
|
act(() =>
|
|
window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })),
|
|
);
|
|
|
|
expect(result.current.view).toBe("discovery");
|
|
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
|
|
});
|
|
|
|
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
|
|
const { result } = render();
|
|
act(() => result.current.setView("hidden"));
|
|
act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })));
|
|
expect(result.current.view).toBe("hidden"); // unchanged
|
|
});
|
|
});
|
|
|
|
describe("ChannelsProvider — S8 shareable URL", () => {
|
|
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
|
|
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
|
|
localStorage.setItem(VIEW_KEY, "blocked");
|
|
const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" }));
|
|
window.history.replaceState(
|
|
{ sfPage: "channels" },
|
|
"",
|
|
`/?ctab=hidden&csort=priority:desc&cf=${cf}`,
|
|
);
|
|
const { result } = render();
|
|
expect(result.current.view).toBe("hidden");
|
|
expect(result.current.sort).toEqual({ key: "priority", dir: "desc" });
|
|
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
|
});
|
|
|
|
it("mirrors the tab into the address bar and clears it back to the default", () => {
|
|
const { result } = render();
|
|
act(() => result.current.setView("blocked"));
|
|
expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked");
|
|
// Back to the default tab clears the key (only non-defaults are emitted).
|
|
act(() => result.current.setView("subscribed"));
|
|
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
|
});
|
|
|
|
it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
|
|
// Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
|
|
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
|
|
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
|
|
localStorage.setItem(
|
|
"siftlode.channelSort.1",
|
|
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
|
);
|
|
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
|
|
sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
|
|
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
|
|
|
const { result } = render();
|
|
act(() => setActiveAccount(1)); // App pins the account after /api/me
|
|
|
|
// The present dim (tab) is adopted from the link...
|
|
expect(result.current.view).toBe("hidden");
|
|
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
|
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
|
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
|
expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
|
|
key: "subscribers",
|
|
dir: "desc",
|
|
});
|
|
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
|
|
sync: "needsFull",
|
|
});
|
|
});
|
|
});
|