The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh reloads a module that also exports hooks or helpers. - Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts), following navigationInit/playlistsInit. - Helpers that sat in component files have their own modules: welcomeMode, downloadTitle, plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent, ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns and the registry's page wrappers are components files of their own (channelCells, modulePages). - usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original. npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
94 lines
4.0 KiB
TypeScript
94 lines
4.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The login card's sub-views (Create an account / Forgot password / Didn't get the email) used to be
|
|
// plain React state: clicking one changed nothing in history, so Back popped to whatever entry came
|
|
// before the card and the address bar moved while the form on screen did not. They now ride
|
|
// useHistorySubview, and this pins the wiring decisions that are specific to THIS card — which modes
|
|
// are mirrored, and that Back walks the views. The hook's own mechanics live in
|
|
// history.subview.dom.test.ts.
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
|
|
import { useHistorySubview } from "../lib/history";
|
|
import { MODE_URL, type Mode } from "./welcomeMode";
|
|
|
|
const render = (root: Mode = "signin") => renderHook(() => useHistorySubview<Mode>(root, MODE_URL));
|
|
|
|
// jsdom's history.back() is asynchronous and does not rewind location, so — as in
|
|
// history.subview.dom.test.ts — a Back is simulated by landing on the target entry and firing
|
|
// popstate. `open()` pushing a REAL entry is asserted separately via history.length.
|
|
const popTo = (search: string, state: unknown = null) =>
|
|
act(() => {
|
|
window.history.replaceState(state, "", search || "/");
|
|
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
|
});
|
|
|
|
// jsdom keeps one history across a file; land on a clean root entry before each test.
|
|
beforeEach(() => window.history.replaceState(null, "", "/"));
|
|
afterEach(() => window.history.replaceState(null, "", "/"));
|
|
|
|
describe("login card mode ↔ URL", () => {
|
|
it("mirrors the three link-reachable views and leaves sign-in as the bare root", () => {
|
|
expect(MODE_URL.encode("signin")).toBeNull();
|
|
expect(MODE_URL.encode("register")).toBe("register");
|
|
expect(MODE_URL.encode("forgot")).toBe("forgot");
|
|
expect(MODE_URL.encode("resend")).toBe("resend");
|
|
});
|
|
|
|
it("never mirrors the reset view", () => {
|
|
// Reset is only reachable with the one-time token from the #reset fragment. Mirroring it would
|
|
// hand out a bookmark that opens a password form with no token behind it.
|
|
expect(MODE_URL.encode("reset")).toBeNull();
|
|
expect(MODE_URL.decode("reset")).toBeNull();
|
|
});
|
|
|
|
it("decodes only real views, so a hand-typed ?m= falls back to sign-in", () => {
|
|
expect(MODE_URL.decode("register")).toBe("register");
|
|
expect(MODE_URL.decode("")).toBeNull();
|
|
expect(MODE_URL.decode("nonsense")).toBeNull();
|
|
});
|
|
|
|
it("opening a view pushes a real history entry, and landing back on the root restores sign-in", () => {
|
|
const { result } = render();
|
|
const entries = window.history.length;
|
|
expect(result.current.view).toBe("signin");
|
|
|
|
act(() => result.current.open("forgot"));
|
|
|
|
// The whole point: a Back has somewhere to go. Before this the click changed state only.
|
|
expect(window.history.length).toBe(entries + 1);
|
|
expect(result.current.view).toBe("forgot");
|
|
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
|
|
|
|
popTo("/");
|
|
|
|
expect(result.current.view).toBe("signin");
|
|
});
|
|
|
|
it("walks back one view at a time when the user hops between them", () => {
|
|
const { result } = render();
|
|
const entries = window.history.length;
|
|
act(() => result.current.open("register"));
|
|
act(() => result.current.open("forgot"));
|
|
expect(window.history.length).toBe(entries + 2); // one entry per view, not one for the lot
|
|
|
|
popTo("/?m=register", { _sub: "register" });
|
|
expect(result.current.view).toBe("register");
|
|
|
|
popTo("/");
|
|
expect(result.current.view).toBe("signin");
|
|
});
|
|
|
|
it("opens on the reset view when a reset token seeded it, without putting it in the URL", () => {
|
|
const { result } = render("reset");
|
|
expect(result.current.view).toBe("reset");
|
|
expect(window.location.search).toBe("");
|
|
});
|
|
|
|
it("restores a bookmarked view from the URL alone", () => {
|
|
window.history.replaceState(null, "", "/?m=resend");
|
|
const { result } = render();
|
|
expect(result.current.view).toBe("resend");
|
|
});
|
|
});
|