|
| 1 | +/** |
| 2 | + * @ai-context motion-coverage.test.ts — **`motion.css` 的 reduced-motion 覆盖守卫**(批 0-D Task 14;node 环境,无 DOM)。 |
| 3 | + * |
| 4 | + * Why(两条判据,第二条堵的是 T11 评审用**真浏览器**实测出来的 Critical): |
| 5 | + * ① **基类覆盖率**(规格 §11 验收 5):每一类原语的**根类**必须在 `motion.css` 的媒体查询名单里, |
| 6 | + * 且名单**双向一致** —— 漏一个 = 那类原语在 reduced-motion 下照旧动;多一个 = 死名字。 |
| 7 | + * 判据 = **基类名单,不是「全类集合 ⊇」**:修饰类(`--`)与子元素/钩子类 |
| 8 | + * (`.ed-modal-head/body/foot` · `.ed-confirm-seal/-impacts/-keep` · `.ed-empty__title`)与基类同在一个 |
| 9 | + * 元素上,已被同一条规则覆盖,逐字枚举只会假红(控制方 2026-09-11 拍定,`progress.md` §十五-3)。 |
| 10 | + * ② **`animation` 声明的选择器原文必须逐字进名单**:`animation-duration` / `animation-iteration-count` |
| 11 | + * **不是可继承属性** ⇒ 覆盖写在宿主元素上,**伪元素拿不到**。T11 评审用 headless Chromium |
| 12 | + * (`--force-prefers-reduced-motion`)实测坐实:`.ed-skeleton` 元素是 `0.001s/1` ✅, |
| 13 | + * 而 **`.ed-skeleton::after` 仍是 `1.2s / infinite / ed-skeleton-shimmer`** ❌ —— |
| 14 | + * 「让两者都静止」的计划验收当时未达成,而文本包含式断言抓不到。⇒ 判据从「基类在名单里」 |
| 15 | + * 升级为「**每一处动画的落点**(元素或伪元素)都在名单里」。 |
| 16 | + * |
| 17 | + * 副作用:只读磁盘(同目录),不修改任何文件。 |
| 18 | + * 边界:① 判据只覆盖 `app/src/ui/primitives/` 一层;② 判据前先归一 EOL、先剥注释(本仓无 `.gitattributes` |
| 19 | + * 且 `core.autocrlf=true`);③ 抽取按**规则块**做(`选择器 { 规则体 }`),不是通用 CSS parser —— 本层 CSS |
| 20 | + * 全部扁平、无嵌套规则、无 `@supports` 内层规则(真 parser 需要新依赖,违反「零新增依赖」); |
| 21 | + * ④ 这里不重述「零颜色字面量 / 接线 / 联合契约 / 时长三方对拍」(`style-seams.test.ts` 与 `style-contract.test.ts`)。 |
| 22 | + */ |
| 23 | +import { readdirSync, readFileSync } from "node:fs"; |
| 24 | +import { dirname, join } from "node:path"; |
| 25 | +import { fileURLToPath } from "node:url"; |
| 26 | +import { describe, expect, it } from "vitest"; |
| 27 | + |
| 28 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 29 | +const normalizeEol = (s: string): string => s.replace(/\r\n/g, "\n"); |
| 30 | +const read = (file: string): string => normalizeEol(readFileSync(join(HERE, file), "utf8")); |
| 31 | +const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, ""); |
| 32 | + |
| 33 | +const CSS_FILES: readonly string[] = readdirSync(HERE).filter((f) => f.endsWith(".css")); |
| 34 | + |
| 35 | +/** 媒体查询里列出的条目(**含伪元素原文**,如 `ed-skeleton::after`) */ |
| 36 | +const MOTION_ENTRIES: readonly string[] = (() => { |
| 37 | + const clean = stripComments(read("motion.css")); |
| 38 | + const block = clean.slice(clean.indexOf("@media (prefers-reduced-motion")); |
| 39 | + return [...block.matchAll(/\.([A-Za-z0-9_-]+(?:::?[A-Za-z-]+)?)/g)].map((m) => m[1]); |
| 40 | +})(); |
| 41 | + |
| 42 | +const BASE_CLASSES: ReadonlyArray<readonly [file: string, base: string]> = [ |
| 43 | + ["Text.css", "ed-text"], |
| 44 | + ["Surface.css", "ed-surface"], |
| 45 | + ["Button.css", "ed-btn"], |
| 46 | + ["Modal.css", "ed-modal"], |
| 47 | + ["Modal.css", "ed-modal-overlay"], |
| 48 | + ["ConfirmDialog.css", "ed-confirm"], |
| 49 | + ["Toast.css", "ed-toast"], |
| 50 | + ["EmptyState.css", "ed-empty"], |
| 51 | + ["Loading.css", "ed-loading"], |
| 52 | + ["Loading.css", "ed-skeleton"], |
| 53 | + ["Loading.css", "ed-probe"], |
| 54 | + ["StatusLine.css", "ed-status"], |
| 55 | +]; |
| 56 | + |
| 57 | +/** |
| 58 | + * 5 个**现存非原语**的 `.ed-*` 名(T3 评审 M-4 实测)。它们不是 CSS 类 —— 是既有标识符里的子串: |
| 59 | + * `ed-desc`←`"updated-desc"` · `ed-label`←`note-link-linked-label` · `ed-note`←`…saved-note` · |
| 60 | + * `ed-milestone-note`←`data-testid="degraded-milestone-note"` · `ed-low-confidence`←`structuredBlocks.ts:58` |
| 61 | + * 产出的类名串(其 `App.css` 规则已随 Task 13 删除)。任何「全树裸扫 `.ed-*`」的写法都会在这 5 个上假红。 |
| 62 | + */ |
| 63 | +const NON_PRIMITIVE_ED_NAMES: readonly string[] = [ |
| 64 | + "ed-desc", |
| 65 | + "ed-label", |
| 66 | + "ed-note", |
| 67 | + "ed-milestone-note", |
| 68 | + "ed-low-confidence", |
| 69 | +]; |
| 70 | + |
| 71 | +/** 全部 `primitives/*.css` 的选择器里出现过的 `.ed-*` 类名(剥注释后;含跨文件引用的基类) */ |
| 72 | +const SELECTOR_NAMES: readonly string[] = [ |
| 73 | + ...new Set(CSS_FILES.flatMap((f) => [...stripComments(read(f)).matchAll(/\.(ed-[A-Za-z0-9_-]+)/g)].map((m) => m[1]))), |
| 74 | +]; |
| 75 | + |
| 76 | +/** |
| 77 | + * 抽取「声明了 `animation` 或 `animation-name` 的选择器**原文**」(含 `::after` 这类伪元素)。 |
| 78 | + * 只看 `animation:` / `animation-name:` —— `animation-duration` / `animation-iteration-count` 是覆盖块 |
| 79 | + * 自己写的属性,不是"动画落点"。 |
| 80 | + */ |
| 81 | +function animationSelectors(): string[] { |
| 82 | + const out: string[] = []; |
| 83 | + for (const file of CSS_FILES) { |
| 84 | + for (const m of stripComments(read(file)).matchAll(/([^{}]+)\{([^{}]*)\}/g)) { |
| 85 | + if (!/(?:^|[;\s])animation(?:-name)?\s*:/.test(m[2])) continue; |
| 86 | + for (const raw of m[1].split(",")) { |
| 87 | + const sel = raw.trim().replace(/\s+/g, " "); |
| 88 | + if (sel !== "" && !sel.startsWith("@")) out.push(sel); |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + return [...new Set(out)]; |
| 93 | +} |
| 94 | + |
| 95 | +describe("reduced-motion 基类覆盖率 100%(规格 §11 验收 5:判据 = 基类名单,不是「全类集合 ⊇」)", () => { |
| 96 | + it("每个基类都真有规则,且都在 `motion.css` 的名单里(漏一个 = 那类原语照旧动)", () => { |
| 97 | + expect(BASE_CLASSES, "基类名单不该缩水").toHaveLength(12); |
| 98 | + for (const [file, base] of BASE_CLASSES) { |
| 99 | + expect(stripComments(read(file)), `${file} 缺少基类 .${base} 的规则`).toContain(`.${base} {`); |
| 100 | + expect(MOTION_ENTRIES, `motion.css 的 reduced-motion 名单缺基类 .${base}`).toContain(base); |
| 101 | + } |
| 102 | + }); |
| 103 | + |
| 104 | + it("名单里没有死条目(每个条目要么是登记基类,要么是某处 `animation` 声明的选择器)", () => { |
| 105 | + const bases = new Set(BASE_CLASSES.map(([, base]) => base)); |
| 106 | + const animated = new Set(animationSelectors()); |
| 107 | + const dead = MOTION_ENTRIES.filter((entry) => !bases.has(entry) && !animated.has(`.${entry}`)); |
| 108 | + expect(dead, `这些条目既不是基类、也不对应任何动画声明(删掉或改对名字):\n${dead.join("\n")}`).toEqual([]); |
| 109 | + }); |
| 110 | + |
| 111 | + it("选择器域里没有未登记的基类(修饰类 `--` / BEM 子元素 `__` / `<基类>-…` 之外一律要进名单)", () => { |
| 112 | + const bases = new Set(BASE_CLASSES.map(([, base]) => base)); |
| 113 | + const unregistered = SELECTOR_NAMES.filter( |
| 114 | + (name) => |
| 115 | + !NON_PRIMITIVE_ED_NAMES.includes(name) && |
| 116 | + !name.includes("--") && |
| 117 | + !name.includes("__") && |
| 118 | + ![...bases].some((base) => name.startsWith(`${base}-`)) && |
| 119 | + !bases.has(name), |
| 120 | + ); |
| 121 | + expect(unregistered, `新增基类必须同时加进 motion.css 与上面的 BASE_CLASSES:\n${unregistered.join("\n")}`).toEqual([]); |
| 122 | + }); |
| 123 | + |
| 124 | + it("口径锚:5 个非原语名不在选择器域里(它们在 TS 标识符里;真成了 CSS 类就该从排除名单摘掉)", () => { |
| 125 | + expect(NON_PRIMITIVE_ED_NAMES).toHaveLength(5); |
| 126 | + for (const name of NON_PRIMITIVE_ED_NAMES) expect(SELECTOR_NAMES).not.toContain(name); |
| 127 | + }); |
| 128 | +}); |
| 129 | + |
| 130 | +describe("★ 每一处 `animation` 声明的选择器都在名单里(`animation-*` 不可继承 ⇒ 伪元素必须逐字列)", () => { |
| 131 | + it("抽取本身非空(防选择器解析失效把守卫静默关掉)", () => { |
| 132 | + const animated = animationSelectors(); |
| 133 | + expect(animated.length, `primitives/*.css 里应能抽到动画落点`).toBeGreaterThanOrEqual(3); |
| 134 | + expect(animated).toContain(".ed-skeleton::after"); |
| 135 | + }); |
| 136 | + |
| 137 | + it("逐条比对:缺一条即红(`.ed-skeleton::after` 被真浏览器实测打不到覆盖 —— T11 评审 Critical)", () => { |
| 138 | + const animated = animationSelectors(); |
| 139 | + const uncovered = animated.filter((sel) => !MOTION_ENTRIES.includes(sel.slice(1))); |
| 140 | + expect( |
| 141 | + uncovered, |
| 142 | + `这些选择器声明了 animation 却不在 motion.css 的 reduced-motion 名单里(伪元素拿不到宿主元素的覆盖):\n${uncovered.join("\n")}`, |
| 143 | + ).toEqual([]); |
| 144 | + }); |
| 145 | +}); |
0 commit comments