Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell, the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate register, with its three imports. Placement: Plex's chip builder moves into lib beside the feed's, the folder names into features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed" itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic. The floating header re-measures when one page's pills replace another's in the same commit (the claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a page with a rail adds in lib.
177 lines
5.6 KiB
TypeScript
177 lines
5.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The floating header knows no page: a page fills it through PageHeader (a portal into the header's
|
|
// root box), and the shell's PageHeaderDefault shows only while no page does. These pin the slot's
|
|
// three promises: the page's pills land IN the header, the default steps aside for them and comes
|
|
// back when the next page has none, and a shell without a header (the channel page) renders the
|
|
// page's pills nowhere.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { render, cleanup, screen } from "@testing-library/react";
|
|
import PageShell, { PageHeader, PageHeaderDefault } from "./PageShell";
|
|
|
|
vi.mock("./PageScroller", () => ({
|
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
observe() {}
|
|
disconnect() {}
|
|
},
|
|
);
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
const hdr = <div data-testid="hdr" />;
|
|
const fallback = (
|
|
<PageHeaderDefault>
|
|
<span>default</span>
|
|
</PageHeaderDefault>
|
|
);
|
|
|
|
describe("the header slot", () => {
|
|
it("puts a page's pills inside the header box", () => {
|
|
render(
|
|
<PageShell header={hdr}>
|
|
<PageHeader>
|
|
<span>search</span>
|
|
</PageHeader>
|
|
</PageShell>,
|
|
);
|
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
|
});
|
|
|
|
it("shows the default on a page with no header of its own", () => {
|
|
render(<PageShell header={hdr}>{fallback}</PageShell>);
|
|
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
|
});
|
|
|
|
it("hides the default while a page fills the header, and brings it back on the next page", () => {
|
|
const page = (own: boolean) => (
|
|
<PageShell header={hdr}>
|
|
{own && (
|
|
<PageHeader>
|
|
<span>search</span>
|
|
</PageHeader>
|
|
)}
|
|
{fallback}
|
|
</PageShell>
|
|
);
|
|
const { rerender } = render(page(true));
|
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
|
rerender(page(false));
|
|
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
|
rerender(page(true));
|
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
|
});
|
|
|
|
it("keeps the default hidden while one of two PageHeaders on a page remains", () => {
|
|
const page = (both: boolean) => (
|
|
<PageShell header={hdr}>
|
|
<PageHeader>
|
|
<span>search</span>
|
|
</PageHeader>
|
|
{both && (
|
|
<PageHeader>
|
|
<span>toggle</span>
|
|
</PageHeader>
|
|
)}
|
|
{fallback}
|
|
</PageShell>
|
|
);
|
|
const { rerender } = render(page(true));
|
|
rerender(page(false));
|
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
|
});
|
|
|
|
it("pads the content by the filled header before paint, not when the observer catches up", () => {
|
|
// jsdom does no layout: the header's height here is one 40px row per pill in it. The observer
|
|
// never fires in this test, so only a measurement made as the pills arrive can see them.
|
|
const height = (el: HTMLElement) => 40 * el.childElementCount;
|
|
const page = (own: boolean) => (
|
|
<PageShell header={hdr}>
|
|
{own && (
|
|
<PageHeader>
|
|
<span>search</span>
|
|
<span>scope</span>
|
|
</PageHeader>
|
|
)}
|
|
{fallback}
|
|
</PageShell>
|
|
);
|
|
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
|
|
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
|
|
get() {
|
|
return height(this as HTMLElement);
|
|
},
|
|
configurable: true,
|
|
});
|
|
const pad = () =>
|
|
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
|
|
"--hdr-h",
|
|
);
|
|
rerender(page(false));
|
|
expect(pad()).toBe("44px"); // the default alone: one row
|
|
rerender(page(true));
|
|
expect(pad()).toBe("84px"); // the page's two pills, the default gone
|
|
});
|
|
|
|
it("re-measures when one page's pills replace another's in the same commit", () => {
|
|
// Feed → Playlists: the leaving page's claim and the arriving page's land in one commit, so the
|
|
// number of claims never changes — the pad must follow the new pills all the same.
|
|
const height = (el: HTMLElement) => 40 * el.childElementCount;
|
|
const Feed = () => (
|
|
<PageHeader>
|
|
<span>search</span>
|
|
<span>scope</span>
|
|
</PageHeader>
|
|
);
|
|
const Playlists = () => (
|
|
<PageHeader>
|
|
<span>search</span>
|
|
</PageHeader>
|
|
);
|
|
const page = (which: "feed" | "playlists") => (
|
|
<PageShell header={hdr}>
|
|
{which === "feed" ? <Feed /> : <Playlists />}
|
|
{fallback}
|
|
</PageShell>
|
|
);
|
|
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
|
|
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
|
|
get() {
|
|
return height(this as HTMLElement);
|
|
},
|
|
configurable: true,
|
|
});
|
|
const pad = () =>
|
|
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
|
|
"--hdr-h",
|
|
);
|
|
rerender(page("feed"));
|
|
expect(pad()).toBe("84px");
|
|
rerender(page("playlists"));
|
|
expect(pad()).toBe("44px");
|
|
rerender(page("feed"));
|
|
expect(pad()).toBe("84px");
|
|
});
|
|
|
|
it("renders a page's pills nowhere in a shell without a header", () => {
|
|
render(
|
|
<PageShell>
|
|
<PageHeader>
|
|
<span>search</span>
|
|
</PageHeader>
|
|
{fallback}
|
|
</PageShell>,
|
|
);
|
|
expect(screen.queryByText("search")).toBeNull();
|
|
expect(screen.queryByText("default")).toBeNull();
|
|
});
|
|
});
|