Files
siftlode/frontend/src/components/ui/SidePanel.drawer.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

187 lines
7.6 KiB
TypeScript

// @vitest-environment jsdom
//
// C2 (DESIGN "Touch"): on touch, and below 768, the side panel is a DRAWER — the tab opens it over the
// content (ui/Modal's start-edge drawer: Esc, Back, the scrim), it never peeks on a tap's emulated
// hover, and pinning is a button in its header offered only ≥ 1024. A mouse on a wide window keeps
// hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel
// asks from three switches, and can flip them live (a tablet turning).
import { describe, it, expect, afterEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.title ? `${k}:${String(o.title)}` : k),
}),
};
});
import SidePanel from "./SidePanel";
type Env = { coarse: boolean; narrow: boolean; wide: boolean };
let env: Env = { coarse: false, narrow: false, wide: true };
const listeners = new Set<() => void>();
function stubMedia(next: Env) {
env = next;
window.matchMedia = ((query: string) => ({
get matches() {
// A comma list is an OR: each part answers from its own switch.
return query
.split(",")
.some((q) =>
q.includes("pointer: coarse")
? env.coarse
: q.includes("max-width: 767.98px")
? env.narrow
: q.includes("min-width: 1024px")
? env.wide
: false,
);
},
media: query,
addEventListener: (_: string, l: () => void) => listeners.add(l),
removeEventListener: (_: string, l: () => void) => listeners.delete(l),
})) as unknown as typeof window.matchMedia;
}
function turn(next: Env) {
act(() => {
env = next;
for (const l of [...listeners]) l();
});
}
afterEach(() => {
cleanup();
listeners.clear();
// @ts-expect-error — jsdom ships none; put it back the way it was
delete window.matchMedia;
});
function mount(collapsed = true) {
const onToggleCollapse = vi.fn();
render(
<SidePanel
title="Filters"
icon={<span />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
>
<p>panel body</p>
</SidePanel>,
);
return { onToggleCollapse };
}
describe("SidePanel · a mouse on a wide window", () => {
it("pins from the tab, as before", () => {
stubMedia({ coarse: false, narrow: false, wide: true });
const { onToggleCollapse } = mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }));
expect(onToggleCollapse).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull();
});
});
// The pinned (or peeking) card covers the tab, which only goes transparent — yet it
// stayed a Tab stop, an invisible control the focus landed on, named just "Unpin panel".
describe("SidePanel · the tab under an open card", () => {
it("is out of the Tab order while the card is open, and names its panel", () => {
stubMedia({ coarse: false, narrow: false, wide: true });
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
mount(false); // pinned
// The card's own pin is the one control by that name; the tab under it is out of the tree too.
const tab = document.querySelector<HTMLElement>(
'[aria-hidden="true"][aria-label="sidebar.unpinNamed:Filters"]',
)!;
expect(tab.tabIndex).toBe(-1);
expect(screen.getAllByRole("button", { name: "sidebar.unpinNamed:Filters" })).toHaveLength(1);
cleanup();
mount(true);
expect(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }).tabIndex).toBe(0);
});
it("hands a key's focus on the tab to the card it peeks open, not to the hidden tab", () => {
stubMedia({ coarse: false, narrow: false, wide: true });
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
mount(true);
const tab = screen.getByRole("button", { name: "sidebar.pinNamed:Filters" });
// A page the browser is not painting runs no animation frame: the step must not wait for one.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0);
act(() => tab.focus()); // jsdom calls every focus :focus-visible — a key's focus, here
// in the commit that opens the card, not a frame later
expect(document.activeElement?.closest("aside")).not.toBeNull();
expect(document.activeElement?.getAttribute("aria-label")).toBe("sidebar.pinNamed:Filters");
expect(document.activeElement).not.toBe(tab);
raf.mockRestore();
});
});
describe("SidePanel · on touch", () => {
it("opens a drawer from the tab instead of pinning, and Esc closes it", () => {
stubMedia({ coarse: true, narrow: false, wide: false });
const { onToggleCollapse } = mount();
expect(screen.queryByText("panel body")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain("panel body");
expect(onToggleCollapse).not.toHaveBeenCalled();
// Below 1024 a pinned panel would leave the content no room: no pin in the drawer.
expect(screen.queryByRole("button", { name: "sidebar.pinNamed:Filters" })).toBeNull();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("never peeks on the emulated hover a tap fires", () => {
stubMedia({ coarse: true, narrow: false, wide: false });
mount();
fireEvent.mouseEnter(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
expect(screen.queryByText("panel body")).toBeNull();
});
it("offers the pin in the drawer's header at 1024 and up, and pinning closes the drawer", () => {
stubMedia({ coarse: true, narrow: false, wide: true });
const { onToggleCollapse } = mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
fireEvent.click(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }));
expect(onToggleCollapse).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("leaves a pin saved on a desktop alone below 1024: a tab, not a pinned card", () => {
stubMedia({ coarse: true, narrow: false, wide: false });
mount(false);
expect(screen.queryByText("panel body")).toBeNull();
expect(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })).toBeTruthy();
});
it("closes for good when the tablet turns to a width where the panel is a card again", () => {
stubMedia({ coarse: false, narrow: true, wide: false });
mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
expect(screen.getByRole("dialog")).toBeTruthy();
turn({ coarse: false, narrow: false, wide: true });
expect(screen.queryByRole("dialog")).toBeNull();
turn({ coarse: false, narrow: true, wide: false });
expect(screen.queryByRole("dialog")).toBeNull();
});
});
describe("SidePanel · a narrow window with a mouse", () => {
it("is a drawer too (split screen, a small window)", () => {
stubMedia({ coarse: false, narrow: true, wide: false });
mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" }));
expect(screen.getByRole("dialog").textContent).toContain("panel body");
});
});