Files
siftlode/frontend/src/components/ui/Button.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

274 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The shared <Button> primitive. These lock the load-bearing contract the migrated call sites rely
// on: variant/size paint (the DESIGN.md B1 ladder and B3 layers), the loading state (disabled +
// spinner + aria-busy), the pressed toggle, and the type="button" default that stops an action button from
// submitting a surrounding form. A glyph-only button is `IconButton` (its own test).
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, cleanup, fireEvent } from "@testing-library/react";
import { Button } from "./Button";
afterEach(cleanup);
describe("Button primitive", () => {
it("defaults to type=button so it never submits a form", () => {
render(<Button>Go</Button>);
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button");
});
it("applies the variant paint recipe", () => {
render(
<Button variant="danger" size="sm">
Delete
</Button>,
);
const btn = screen.getByRole("button", { name: "Delete" });
expect(btn.className).toContain("state-danger");
expect(btn.className).toContain("h-7");
});
it("puts every rung on an explicit height, so a toolbar row lines up (B1)", () => {
for (const [size, h] of [
["xs", "h-6"],
["sm", "h-7"],
["md", "h-8"],
] as const) {
const { unmount } = render(<Button size={size}>X</Button>);
const cls = screen.getByRole("button", { name: "X" }).className.split(/\s+/);
expect(cls).toContain(h);
expect(cls).toContain("rounded-lg");
expect(cls.filter((c) => c.startsWith("py-"))).toEqual([]);
unmount();
}
});
it("hovers with a state layer, never an opacity dim or a card fill (B3)", () => {
for (const variant of ["outline", "ghost", "outline-danger", "danger-soft"] as const) {
const { unmount } = render(<Button variant={variant}>X</Button>);
const cls = screen.getByRole("button", { name: "X" }).className;
expect(cls).toContain("state-layer");
expect(cls).not.toMatch(/hover:(bg-card|bg-surface|opacity)/);
unmount();
}
});
it("a toggle announces aria-pressed and paints the pressed state", () => {
const { rerender } = render(
<Button variant="outline" pressed={false}>
Default
</Button>,
);
const btn = screen.getByRole("button", { name: "Default" });
expect(btn.getAttribute("aria-pressed")).toBe("false");
expect(btn.className).toContain("aria-pressed:bg-accent");
rerender(
<Button variant="outline" pressed>
Default
</Button>,
);
expect(btn.getAttribute("aria-pressed")).toBe("true");
});
it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
const onClick = vi.fn();
render(
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
Save
</Button>,
);
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
expect(btn.getAttribute("aria-busy")).toBe("true");
expect(btn.getAttribute("aria-disabled")).toBe("true");
// the leading icon is swapped for a spinner
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
// Measured in Chrome: a focused button that becomes natively disabled drops the focus to
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
it("loading keeps the button natively enabled, so it keeps the focus", () => {
render(<Button loading>Reroll</Button>);
const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it("loading blocks clicks even when an explicit disabled={false} is passed", () => {
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
// clickable mid-mutation and double-submitted. The contract is: loading always blocks.
const onClick = vi.fn();
render(
<Button disabled={false} loading onClick={onClick}>
Create
</Button>,
);
fireEvent.click(screen.getByRole("button", { name: "Create" }));
expect(onClick).not.toHaveBeenCalled();
});
it("a busy submit button cancels the form's submission", () => {
// Enter in a field submits through a click on the default button; the guard cancels that click.
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
render(
<form onSubmit={onSubmit}>
<Button type="submit" loading>
Send
</Button>
</form>,
);
fireEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onSubmit).not.toHaveBeenCalled();
});
it("a non-busy button still calls onClick", () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Go</Button>);
fireEvent.click(screen.getByRole("button", { name: "Go" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("a link gets no button padding, and an explicit size still wins", () => {
// The size default is variant-dependent (link → "none"). Locking it because the failure is
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
const { rerender } = render(<Button variant="link">Show all</Button>);
let btn = screen.getByRole("button", { name: "Show all" });
expect(btn.className).toContain("text-accent");
expect(btn.className).not.toContain("px-3");
rerender(
<Button variant="link" size="sm">
Show all
</Button>,
);
btn = screen.getByRole("button", { name: "Show all" });
expect(btn.className).toContain("px-2.5");
});
it("keeps one font-weight utility whatever the variant", () => {
// Two font-* utilities of equal specificity are decided by stylesheet order, not class order,
// so a variant must never add a second one (there is no tailwind-merge here).
for (const variant of ["primary", "link", "glass", "outline"] as const) {
const { unmount } = render(<Button variant={variant}>X</Button>);
const cls = screen.getByRole("button", { name: "X" }).className;
expect(cls.split(/\s+/).filter((c) => c.startsWith("font-")).length).toBe(1);
unmount();
}
});
it("carries the coarse-pointer minimum target and the 24 floor, a link variant too", () => {
// The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
// out of it by accident. A link variant was once excluded as "text in a sentence", but every
// one stands alone, and an audit measured them at 16–20px.
const { unmount } = render(<Button>Tap</Button>);
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
unmount();
for (const variant of ["link", "link-muted"] as const) {
const { unmount: u } = render(<Button variant={variant}>Read more</Button>);
const cls = screen.getByRole("button", { name: "Read more" }).className;
expect(cls).toContain("touch-target");
expect(cls).toContain("min-h-6");
u();
}
});
it("fullWidth adds the w-full utility", () => {
render(<Button fullWidth>Wide</Button>);
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
});
});
// --- the accessible-name contract, pinned at the TYPE level ------------------------------------
// A button that renders no text must carry a name, and that rule is a union in Button.tsx rather
// than a doc comment because the comment version let two nameless icon buttons ship. But a
// type with no test is a type someone widens to silence a stubborn call site:
// replacing `ButtonProps` with the permissive shape left tsc, eslint, knip and vitest all green.
// Each `@ts-expect-error` below FAILS THE BUILD if the error it expects stops happening,
// so weakening the union turns the whole typecheck lane red.
//
// Rendered, not just declared, so eslint sees them used and the file stays one test suite.
describe("Button · disabled while it holds the focus", () => {
// A focused button that turns natively disabled loses the focus to <body> in Chrome (measured)
// — and ~60 call sites disable the very button that was just pressed while its mutation runs.
// jsdom does not move the focus, so the CONTRACT is what is pinned: while focused, the button is
// unavailable by aria-disabled + a click guard, never by the native attribute.
it("keeps the native attribute off while focused, and blocks activation", () => {
const onClick = vi.fn();
const { rerender } = render(<Button onClick={onClick}>Save</Button>);
const btn = screen.getByRole("button", { name: "Save" });
act(() => btn.focus());
rerender(
<Button onClick={onClick} disabled>
Save
</Button>,
);
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("also holds when the window has no focus and no focus event ever fired", () => {
// Chrome, with another app in front: `.focus()` moves activeElement and fires nothing. The
// button asks the document at render time, so it does not rely on the event (measured).
const { rerender } = render(<Button onClick={() => {}}>Next</Button>);
const btn = screen.getByRole("button", { name: "Next" });
const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(btn);
rerender(
<Button onClick={() => {}} disabled>
Next
</Button>,
);
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
active.mockRestore();
});
it("keeps a caller's aria-disabled focusable and inert to a click (unavailable with a reason)", () => {
const onClick = vi.fn();
render(
<Button onClick={onClick} aria-disabled>
Run all
</Button>,
);
const btn = screen.getByRole("button", { name: "Run all" });
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("is natively disabled again once the focus is elsewhere", () => {
render(
<Button onClick={() => {}} disabled>
Save
</Button>,
);
const btn = screen.getByRole("button", { name: "Save" });
expect(btn.hasAttribute("disabled")).toBe(true);
});
});
describe("Button accessible-name contract (type level)", () => {
it("rejects a text-less button with no name, and accepts every named spelling", () => {
render(
<>
{/* @ts-expect-error an icon with no children renders no text and no name */}
<Button icon={<span />} data-testid="bad-c" />
{/* @ts-expect-error the icon-only sizes moved to IconButton */}
<Button size="icon" label="gone" />
<Button icon={<span />} label="by label" />
<Button icon={<span />} aria-label="by aria-label" />
<Button icon={<span />} title="by title" />
<Button>plain text needs none</Button>
{/* @ts-expect-error a toggle states itself through `pressed`, which also paints it (B3) */}
<Button aria-pressed>raw toggle</Button>
<Button pressed>toggle</Button>
</>,
);
expect(screen.getByRole("button", { name: "by label" })).toBeTruthy();
expect(screen.getByRole("button", { name: "by aria-label" })).toBeTruthy();
expect(screen.getByRole("button", { name: "by title" })).toBeTruthy();
});
});