mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
120 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
});
|