Files
HealthLog/src/components/__tests__/motion-reduce-spin-coverage.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

120 lines
4.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.43 W11 — motion-reduce sweep guard.
*
* Every `animate-spin` site in component / app code must also carry
* `motion-reduce:animate-none` so that motion-sensitive users see a
* static icon instead of a continuous rotation. The audit found 21
* sites missing the modifier; this test prevents regressions.
*
* Scope: `src/**` excluding test files. The check is textual to avoid
* rendering every loading state — a CI-cheap guard that catches drift
* any time a new spinner is added without the modifier.
*/
const ROOT = join(process.cwd(), "src");
const EXCLUDE_DIRS = new Set(["__tests__", "node_modules"]);
function collectTsxFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (EXCLUDE_DIRS.has(entry)) continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
collectTsxFiles(full, acc);
} else if (
entry.endsWith(".tsx") &&
!entry.endsWith(".test.tsx") &&
!entry.endsWith(".test.ts")
) {
acc.push(full);
}
}
return acc;
}
describe("motion-reduce coverage — every animate-spin pairs with motion-reduce:animate-none", () => {
const files = collectTsxFiles(ROOT);
it("collects component files to scan", () => {
expect(files.length).toBeGreaterThan(50);
});
it("every animate-spin site declares motion-reduce:animate-none", () => {
const violations: string[] = [];
for (const file of files) {
const src = readFileSync(file, "utf8");
const lines = src.split("\n");
lines.forEach((line, idx) => {
if (!line.includes("animate-spin")) return;
// Skip comments and string literals that aren't classNames.
if (line.trim().startsWith("//") || line.trim().startsWith("*")) return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${line.trim()}`);
}
});
}
expect(
violations,
`The following animate-spin sites are missing motion-reduce:animate-none. ` +
`Append the modifier so motion-sensitive users see a static icon:\n` +
violations.join("\n"),
).toEqual([]);
});
});
/**
* v1.11.3 — `.animate-insight-in` reduced-motion guard.
*
* Unlike `animate-spin` (a Tailwind utility paired with an inline
* `motion-reduce:animate-none` per site), `.animate-insight-in` is a custom
* keyframe utility defined in `globals.css` and applied across five insight
* surfaces (recommendations-grid, recommendation-card, insight-status-card,
* hero-strip, arztbericht-hero-card). Guarding it at each call site would be
* fragile, so the guard lives once in `globals.css`: a
* `@media (prefers-reduced-motion: reduce)` block collapses the animation to
* `none`, covering every consumer at once. This test pins that central guard
* so a future edit cannot drop it and silently re-introduce a 400 ms motion
* for motion-sensitive users.
*/
describe("motion-reduce coverage — animate-insight-in collapses under prefers-reduced-motion", () => {
const GLOBALS = join(process.cwd(), "src", "app", "globals.css");
const css = readFileSync(GLOBALS, "utf8");
it("globals.css defines the .animate-insight-in utility", () => {
expect(css).toMatch(/\.animate-insight-in\s*\{[^}]*animation:/);
});
it("a prefers-reduced-motion block collapses .animate-insight-in to no motion", () => {
// Locate the reduced-motion media query, then assert that within its
// body `.animate-insight-in` is reset to `animation: none`. The check
// is anchored on the media query so a stray `.animate-insight-in {
// animation: none }` outside a reduced-motion context cannot satisfy it.
const mediaStart = css.search(
/@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)/,
);
expect(
mediaStart,
"Expected a `@media (prefers-reduced-motion: reduce)` block in globals.css.",
).toBeGreaterThanOrEqual(0);
// Scan from the media query to the end of the file — every motion the app
// defers under reduced motion lives in such a block, and `.animate-insight-in`
// must be one of them.
const fromMedia = css.slice(mediaStart);
const guarded = /\.animate-insight-in\s*\{\s*animation:\s*none/.test(
fromMedia,
);
expect(
guarded,
"Expected `.animate-insight-in { animation: none }` inside a " +
"`@media (prefers-reduced-motion: reduce)` block in globals.css. " +
"Without it the 400 ms entrance keyframe plays for motion-sensitive " +
"users on every insight card.",
).toBe(true);
});
});