- Button: `loading` always disables (|| not ??) — stops a double-submit when a call-site passes an explicit boolean `disabled` (VideoEditor). - playlists: track un-ingestable-but-live YouTube ids in the sync baseline so a linked playlist containing a private/deleted video converges instead of re-classifying it as a pending pull forever; re-attempt ingestion each sync so a restored (private->public) video heals, and prune ids that leave YouTube. - SettingsPanel: download-auth section renders an honest error/loading state (StateMessage + retry) instead of a bare empty section on a failed credentials fetch; HU+EN loadError string. - tests: reorder-order, partial-failure baseline hold, un-ingestable converge/heal/prune, Button loading-disables, download-auth error/retry.
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// S5: the shared <Button> primitive. These lock the load-bearing contract the 120+ migrated call
|
|
// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
|
|
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
|
|
// stops an action button from submitting a surrounding form.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
import { Button } from "./Button";
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("Button primitive (S5)", () => {
|
|
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("btn-danger");
|
|
expect(btn.className).toContain("py-1.5");
|
|
});
|
|
|
|
it("exposes an accessible name for an icon-only button while dropping the text", () => {
|
|
render(
|
|
<Button iconOnly icon={<svg data-testid="ic" />} label="Block channel">
|
|
this text is dropped
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Block channel" });
|
|
expect(btn.getAttribute("title")).toBe("Block channel");
|
|
expect(btn.textContent).not.toContain("this text is dropped");
|
|
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
|
});
|
|
|
|
it("loading disables the button, marks aria-busy, and blocks clicks", () => {
|
|
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.disabled).toBe(true);
|
|
expect(btn.getAttribute("aria-busy")).toBe("true");
|
|
// the leading icon is swapped for a spinner
|
|
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
|
fireEvent.click(btn);
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loading disables 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 disables.
|
|
const onClick = vi.fn();
|
|
render(
|
|
<Button disabled={false} loading onClick={onClick}>
|
|
Create
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(true);
|
|
fireEvent.click(btn);
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fullWidth adds the w-full utility", () => {
|
|
render(<Button fullWidth>Wide</Button>);
|
|
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
|
});
|
|
});
|