Files
smart-org/apps/web/src/test/motion.test.tsx
T
mjalmoudy94 df2c8546db افزودن سیستم Motion به تمام بخش‌های رابط کاربری
واژگان مشترک حرکت در globals.css: شش @keyframes و شش کلاس .anim-*
به‌همراه .anim-stagger برای ورود آبشاری. همهٔ مدت‌زمان‌ها پشت custom
property هستند تا یک override کل اپ را تنظیم کند.

- packages/ui: Button، Card، Stat، Loading/Empty/ErrorState، UsageBar،
  Timeline، Modal، Drawer، Table، ToastStack و ArtifactViewer
- ریشهٔ هر صفحهٔ (app) با .anim-stagger وارد می‌شود
- AppShell: محتوا fade، منوی کنار stagger، منوهای کشویی slide-up
- prefers-reduced-motion مدت‌زمان‌ها را به ۰.۰۱ms می‌رساند تا
  وضعیت پایانی حفظ شود
- قرارداد motion در src/test/motion.test.tsx تست می‌شود تا
  بازنویسی بعدی آن را بی‌صدا حذف نکند
- تست‌های فرانت‌اند: ۳۵ ← ۴۵
2026-10-13 02:48:59 +03:30

141 lines
4.7 KiB
TypeScript

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<TableColumn<Row>> = [
{ 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(<Modal open title="title" onClose={() => 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(
<Drawer open title="details" onClose={() => undefined}>
<p>body</p>
</Drawer>,
);
expect(container.querySelector("aside.anim-drawer-start")).not.toBeNull();
});
it("slides each toast up", () => {
const { container } = render(
<ToastStack toasts={[{ id: "t1", title: "saved" }]} onDismiss={() => undefined} />,
);
expect(container.querySelector(".anim-slide-up")).not.toBeNull();
});
it("staggers table rows", () => {
const { container } = render(
<Table columns={columns} rows={ROWS} rowKey={(row) => row.id} />,
);
expect(container.querySelector("tbody.anim-stagger")).not.toBeNull();
});
it("rises the summary and async states", () => {
const states = [
render(<EmptyState title="nothing here" />),
render(<ErrorState title="failed" />),
render(<Stat label="credit" value="0" />),
];
for (const { container } of states) {
expect(container.querySelector(".anim-rise")).not.toBeNull();
}
});
it("staggers the timeline and transitions the usage fill", () => {
const timeline = render(<Timeline items={[{ id: "a", title: "event" }]} />);
expect(timeline.container.querySelector("ol.anim-stagger")).not.toBeNull();
const bar = render(<UsageBar used={40} total={100} />);
expect(bar.container.querySelector(".transition-\\[width\\]")).not.toBeNull();
});
});