import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { render } from "@testing-library/react"; import { Drawer, EmptyState, ErrorState, Modal, Stat, Table, Timeline, ToastStack, UsageBar, type TableColumn, } from "@agy/ui"; /** * The motion contract. * * These assert that animated surfaces actually carry an animation class, and * that every class they carry has keyframes behind it. A `className` with no * matching `@keyframes` is a silent no-op — nobody notices until the app feels * dead — so the vocabulary and the surfaces are checked together. * * Timing and feel are deliberately not asserted here: they are a design * decision, not behaviour, and a test that pins them down only fights the next * tuning pass. */ interface Row { id: string; name: string; } const columns: Array> = [ { key: "name", header: "name", render: (row) => row.name }, ]; const ROWS: Row[] = [ { id: "1", name: "one" }, { id: "2", name: "two" }, ]; describe("motion vocabulary", () => { const globals = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf-8"); it("declares every keyframe the utilities reference", () => { for (const name of [ "anim-fade-in", "anim-rise", "anim-scale-in", "anim-slide-up", "anim-drawer-in-start", "anim-drawer-in-start-rtl", ]) { expect(globals).toContain(`@keyframes ${name}`); } }); it("keeps each duration behind a custom property so one override retunes the app", () => { for (const token of ["--anim-fast", "--anim-base", "--anim-slow", "--anim-ease"]) { expect(globals).toContain(token); } // Every utility must read those tokens, not a hard-coded value. expect(globals).not.toMatch(/animation:[^;]*\b\d+ms\b/); }); it("honours prefers-reduced-motion", () => { expect(globals).toContain("@media (prefers-reduced-motion: reduce)"); // Durations collapse, but the end state must still be reachable — so the // animation is kept at a near-zero duration rather than being removed. expect(globals).toMatch(/animation-duration:\s*0\.01ms !important/); expect(globals).toMatch(/transition-duration:\s*0\.01ms !important/); }); it("never animates a property that is not also stated by the end state", () => { // `both` fill mode is what makes an animation land on its final frame even // when the element is removed mid-flight or never re-rendered. for (const utility of ["anim-fade-in", "anim-rise", "anim-scale-in", "anim-slide-up"]) { expect(globals).toMatch( new RegExp(`\\.${utility}\\s*\\{[^}]*both\\s*;`), ); } }); }); describe("animated surfaces", () => { it("scales the modal panel in and fades its backdrop", () => { const { container } = render( undefined} />); const panel = container.querySelector(".anim-scale-in"); expect(panel).not.toBeNull(); // The backdrop is the panel's parent: it owns the dialog role and fades. const backdrop = panel?.parentElement; expect(backdrop).toHaveAttribute("role", "dialog"); expect(backdrop?.className).toContain("anim-fade-in"); }); it("slides the drawer in from its edge", () => { const { container } = render( undefined}>

body

, ); expect(container.querySelector("aside.anim-drawer-start")).not.toBeNull(); }); it("slides each toast up", () => { const { container } = render( undefined} />, ); expect(container.querySelector(".anim-slide-up")).not.toBeNull(); }); it("staggers table rows", () => { const { container } = render( row.id} />, ); expect(container.querySelector("tbody.anim-stagger")).not.toBeNull(); }); it("rises the summary and async states", () => { const states = [ render(), render(), render(), ]; for (const { container } of states) { expect(container.querySelector(".anim-rise")).not.toBeNull(); } }); it("staggers the timeline and transitions the usage fill", () => { const timeline = render(); expect(timeline.container.querySelector("ol.anim-stagger")).not.toBeNull(); const bar = render(); expect(bar.container.querySelector(".transition-\\[width\\]")).not.toBeNull(); }); });