Skip to content

Commit 4c422e0

Browse files
committed
test(shell): 改判顶栏与首访加载态动效断言
1 parent b0ab516 commit 4c422e0

4 files changed

Lines changed: 103 additions & 11 deletions

File tree

‎app/src/shell/ShellFallback.test.tsx‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ import { ALL_ENTRIES } from "./navRegistry";
2929
/** `App.tsx` 的**只留代码**版本(剥块注释 + 整行 `//`;与 `TopBar.test.tsx:43` 同一口径) */
3030
const APP = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "App.tsx"), "utf8")
3131
.replace(/\r\n/g, "\n").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
32+
/**
33+
* G2 改判(T14)的**真源侧**只读件:`motion.css` 的唯一 reduced-motion 块。
34+
* Why 要它:首访加载态的动效**不由行内 style 承担**,而由 `Loading` 原语的类承担 ⇒ 「行内只许 token」
35+
* 这条只封住一条路,另一条路(自造一个没进名单的动效载体类)必须同时封。
36+
*/
37+
const MOTION_CSS = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "ui", "primitives", "motion.css"), "utf8")
38+
.replace(/\r\n/g, "\n").replace(/\/\*[\s\S]*?\*\//g, "");
39+
const MOTION_REDUCED = MOTION_CSS.slice(MOTION_CSS.indexOf("@media (prefers-reduced-motion"));
40+
/** G2 改判的扫描口径:一条行内 `style` 里**没走 token** 的 `transition|animation` 声明(空数组 = 都走 token) */
41+
const inlineMotionViolations = (style: string): string[] =>
42+
style.split(";").filter((d) => /(?:transition|animation)/.test(d) && !/var\(--ed-(?:dur|ease)/.test(d));
3243
/** 模拟懒 chunk 加载失败(渲染期抛错 ⇒ React 落到最近的上层边界);抛错时 React 仍会 console.error ⇒ 抑制后还原 */
3344
function Boom(): never { throw new Error("模拟懒 chunk 加载失败"); }
3445
/**
@@ -55,12 +66,33 @@ function silenced(fn: () => void) {
5566
afterEach(cleanup);
5667

5768
describe("首访加载态 ShellFallback(批 2 未做 #6)", () => {
58-
it("渲染一行文字且是 role=status;内联样式里没有动效(动效属批 6)", () => {
69+
/**
70+
* G2 改判(R1.2 / §七 G2 / R4.2):旧断言「内联样式里**没有**动效」⇒「内联动效**只许 token**」。
71+
* 🔴 旧口径在本批已是**空真**:批 4 T9 把自足内联 `<div style=…>` 整块删除、改用 L1 原语
72+
* (`Loading`)⇒ 该元素**根本没有 `style` 属性**,`?? ""` 上的 `not.toMatch` 恒成立(T14 实测
73+
* `getAttribute("style") === null`)。⇒ 新口径必须自己带**无条件的那一半**,否则改判只是换个写法空真:
74+
* ① 内联动效若回归,时长/缓动只许 `var(--ed-dur-*)` / `var(--ed-ease*)`(+ 仪器双跑自证);
75+
* ② 动效**必须由类承担**,且那些基类在 `motion.css` 唯一的 reduced-motion 名单里(行内值绕不过
76+
* 名单,类才走得到)。判**基类**:修饰类与基类同在一个元素上、已被同一条规则覆盖(逐字枚举
77+
* 修饰类会假红 —— 与 `motion-coverage.test.ts` 的口径一致)。
78+
*/
79+
it("渲染一行文字且是 role=status;内联动效只许 token、动效一律走类(改判自「禁内联动效」)", () => {
5980
render(<ShellFallback />);
6081
const el = screen.getByRole("status");
6182
expect(el.textContent).toBe("正在载入…");
6283
expect(el.getAttribute("data-testid")).toBe("shell-fallback");
63-
expect(el.getAttribute("style") ?? "").not.toMatch(/transition|animation/);
84+
const bad = inlineMotionViolations(el.getAttribute("style") ?? "");
85+
expect(bad, `首访加载态的内联动效只许 var(--ed-dur-*) / var(--ed-ease*):${bad.join(" / ")}`).toEqual([]);
86+
// 仪器自证(双跑):反例恰 1 条(**逐序数组相等**)、正例 0 条 —— 否则「0 条」是提取器坏了
87+
expect(inlineMotionViolations("transition: opacity 150ms"), "提取器抓不到裸值内联动效 ⇒ 上面是空真").toEqual([
88+
"transition: opacity 150ms",
89+
]);
90+
expect(inlineMotionViolations("transition: opacity var(--ed-dur-toast-in, 180ms)"), "token 正例被判成犯规 ⇒ 上面会假红").toEqual([]);
91+
// ② 无条件的那一半:动效载体是类,且基类在唯一的 reduced-motion 名单里
92+
const bases = [...new Set((el.className || "").split(/\s+/).filter(Boolean).map((c) => c.split("--")[0]))];
93+
expect(bases.length, "加载态根元素一个类都没有 ⇒ 下面的名单判据是空真").toBeGreaterThan(0);
94+
const uncovered = bases.filter((c) => !new RegExp(`\\.${c}(?![\\w-])`).test(MOTION_REDUCED));
95+
expect(uncovered, `这些基类不在 motion.css 的 reduced-motion 名单里 ⇒ 首访加载态在 reduced-motion 下照旧动:${uncovered.join(" / ")}`).toEqual([]);
6496
expect(screen.queryByRole("alert"), "加载态不是错误态").toBeNull();
6597
});
6698
});

‎app/src/shell/TopBar.css‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22
* TopBar.css — A′ 顶栏(规格 §6.1)。批 3 只做静态布局与两档溢出。
33
*
44
* Why 需要这个文件(现网 App.tsx 的顶栏是内联 style):两档溢出是**媒体查询**的活,内联 style
5-
* 表达不了。动效(墨渍 hover / 按下微陷 / 焦点环落纸)属批 6 ⇒ 本文件**不得**出现
6-
* transition / animation / @keyframes(TopBar.test.tsx 的静态守卫逐条扫)。
5+
* 表达不了。动效(墨渍 hover / 按下微陷 / 焦点环落纸)批 3 时**属批 6**;批 6 T14 起按 R1.2 的
6+
* 到期改判**解禁为条件式** —— 本文件**可以**写 transition / animation,但三个条件必须同时成立:
7+
* ① 时长与缓动只许 `var(--ed-dur-*)` / `var(--ed-ease*)`(裸 ms/s 与裸曲线一律红);
8+
* ② 落点必须落在 `motion.css` 的**元素级回执**覆盖面内(顶栏交互元素全是裸 `<button>` ⇒ 由
9+
* `button:not(.ed-btn)` 承担)⇒ 本文件**不自写**第二条 = 不造第二个真源(R1.1);
10+
* ③ **仍不得**出现 `@keyframes`(§8.2 桶边界:keyframes 只留 Loading/Skeleton/Probe),也**不得**
11+
* 自写 `@media (prefers-reduced-motion…)`(`app/src` 内唯一那条块在 `motion.css`)。
12+
* 判据 = `TopBar.test.tsx` 的 ③ 条(token 双向 + 类序 + 回执真源 + 第二条块)。
713
*
814
* 高度消费 --ed-nav-h(批 3 T2 的 token 真源);三处档位数值消费 `shell/breakpoints.ts`
915
* (nav / navFull / navActionsFull),但 CSS 媒体查询**读不到 TS 常量** ⇒ 两侧必须同值,

‎app/src/shell/TopBar.test.tsx‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
* ① 结构:8 个域 Tab + ⚙ 齿轮 + ⌘K;设置**不在**域 Tab 里;`right` 插槽确实渲染在右侧区;
77
* ② 两档溢出:每个 Tab **同时**有 label 元素与 `title` ⇒ 「1024–1179 收起文字后靠 title 当悬浮名」
88
* 这条规格要求是 **DOM 可断言的**;A2 裁决(emoji 出局)另有一条**带阳性对照**的判据;
9-
* ③ 静态纪律:`TopBar.css` 里不得出现 transition / animation / @keyframes(动效属批 6);
10-
* 高度消费 `--ed-nav-h`;两档断点与 `shell/breakpoints.ts` 同值;顶栏项不许被压缩(陷阱 #15);
9+
* ③ 静态纪律(G1 改判,T14):`TopBar.css` 的动效**只许 token**(`var(--ed-dur-*)` / `var(--ed-ease*)`)
10+
* 且**落点必须留在 `motion.css` 元素级回执的覆盖面内**(顶栏交互元素全是裸 `<button>`);
11+
* `@keyframes` 与第二条 reduced-motion 块仍**禁**。另:高度消费 `--ed-nav-h`;两档断点与
12+
* `shell/breakpoints.ts` 同值;顶栏项不许被压缩(陷阱 #15);
1113
* ④ 裁决 A3:AI toast **不在**导航行里(批 3 时它是 `App.tsx` 的 `MainShell` 最外层 fixed 覆盖层;
1214
* **批 4 T10 起交给 `ui/primitives/Toast`** ⇒ B13/B15 授权把定位/锚点/层级三条判据搬到原语层,
1315
* 第四条(`ai-toast` 的 testid)改由 `components/toastMigration.test.tsx` 的**渲染级**断言保住),
@@ -35,6 +37,12 @@ import { BREAKPOINTS } from "./breakpoints";
3537
const HERE = dirname(fileURLToPath(import.meta.url));
3638
/** 判据前先剥注释(与 `ui/primitives` 层同一口径):注释里提到 transition 不算犯规 */
3739
const CSS = readFileSync(join(HERE, "TopBar.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
40+
/**
41+
* G1 改判(T14)的**真源侧**只读件:`motion.css` 的元素级回执 `button:not(.ed-btn)` 与那条唯一的
42+
* reduced-motion 块。顶栏的交互元素全是裸 `<button>` ⇒ 顶栏的时长/缓动**不是顶栏自己的事**:
43+
* 真源在那条元素级规则里,顶栏再写一份就是 R1.1 明禁的第二个真源(`motion.css:63` 同款论证)。
44+
*/
45+
const MOTION_CSS = readFileSync(join(HERE, "..", "ui", "primitives", "motion.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
3846
const TOPBAR_TSX = readFileSync(join(HERE, "TopBar.tsx"), "utf8");
3947
const APP_TSX = readFileSync(join(HERE, "..", "App.tsx"), "utf8");
4048
/**
@@ -64,6 +72,18 @@ const TOAST_BELOW_NAV_BODY = toastRuleBody(".ed-toast--below-nav");
6472
/** emoji / 图形字符(A2 裁决的机器判据)。`\p{Extended_Pictographic}` 是 Unicode 属性类,覆盖 ✨📡🎙 等 */
6573
const PICTO = /\p{Extended_Pictographic}/u;
6674

75+
/**
76+
* G1 改判的**扫描口径**(一处定义,判据与仪器自证共用 ⇒ 不会各写一份正则而漂移)。
77+
* `withoutTokenVars` 必须先剥掉 `var(--ed-x, <同值兜底>)` 整段:本仓的 token 习惯写法**带同值兜底**
78+
* (`--ed-dur-micro, 120ms`)⇒ 不剥的话「不得写裸 ms/s」会把合法 token 用法判成犯规(**假红**,
79+
* 逼实施者绕开守卫 —— 计划 Task 14 的 G1 新断言原文就有这个洞,T14 实施时实测并修正)。
80+
*/
81+
const motionDecls = (css: string): string[] =>
82+
css.split(";").filter((d) => /(?:^|[;\s])(?:transition|animation)(?:-duration|-name)?\s*:/.test(d));
83+
const withoutTokenVars = (decl: string): string => decl.replace(/var\(--ed-[a-z0-9-]+,[^)]*\)/g, "");
84+
const rawTimes = (decls: string[]): string[] => decls.filter((d) => /\d+(?:\.\d+)?\s*(?:ms|s)\b/.test(withoutTokenVars(d)));
85+
const rawEases = (decls: string[]): string[] => decls.filter((d) => /cubic-bezier|ease-(?:in|out|in-out)\b/.test(withoutTokenVars(d)));
86+
6787
afterEach(cleanup);
6888

6989
const base = { page: "classroom" as const, onSelect: vi.fn(), onOpenSettings: vi.fn(), onOpenPalette: vi.fn() };
@@ -136,10 +156,42 @@ describe("TopBar(规格 §6.1)", () => {
136156
expect(onOpenPalette).toHaveBeenCalledTimes(1);
137157
});
138158

139-
it("③ 顶栏 CSS 里没有动效声明(批 6 才加)", () => {
140-
const banned = [/transition\s*:/, /animation\s*:/, /@keyframes/];
141-
const hits = banned.filter((re) => re.test(CSS)).map(String);
142-
expect(hits, `批 3 的顶栏 CSS 不得含动效(属批 6):${hits.join(" / ")}`).toEqual([]);
159+
/**
160+
* G1 改判(R1.2 / §七 G1 / R4.2):旧断言「顶栏 CSS 里没有动效声明」⇒「动效只许 token + 落点可被
161+
* reduced-motion 覆盖」。**为什么不做计划 Task 14 的 `decls.length > 0`**:T12 的元素级回执
162+
* (`motion.css:66-73` 的 `button:not(.ed-btn)`)已落在顶栏每个交互元素上(域 Tab 与两个动作按钮
163+
* 都是裸 `<button>`)⇒ 顶栏再写一份 = R1.1 明禁的第二个真源(同 `motion.css:63` 对 `.ed-btn` 的论证)。
164+
* 故:① 真写动效时时长/缓动只许 token;② `@keyframes` 仍禁(§8.2 桶边界);③ **无条件** —— 顶栏的
165+
* 交互元素必须留在那条回执的覆盖面内,且回执必须存在、声明时长、在那条唯一的 reduced-motion 名单里。
166+
* 解禁新开的洞(本文件自写第二条 reduced-motion 块)一并封住。
167+
*/
168+
it("③ 顶栏动效只许 token,且落点必须留在元素级回执的覆盖面内(改判自「一律禁止」)", () => {
169+
const decls = motionDecls(CSS);
170+
const times = rawTimes(decls);
171+
const eases = rawEases(decls);
172+
expect(times, `顶栏 CSS 的时长必须走 var(--ed-dur-*),不得写裸 ms/s:${times.join(" / ")}`).toEqual([]);
173+
expect(eases, `顶栏 CSS 的缓动必须走 var(--ed-ease*),不得写裸曲线:${eases.join(" / ")}`).toEqual([]);
174+
expect(CSS, "顶栏 CSS 出现 @keyframes(§8.2 桶边界:keyframes 只留 Loading/Skeleton/Probe)").not.toContain("@keyframes");
175+
// 仪器自证(双跑):反例各报 1 条、正例各报 0 条 —— 否则上面的「0 条」是正则坏了,不是「没犯规」
176+
const bad = motionDecls(".a { transition: color 120ms ease-out; }");
177+
const good = motionDecls(".a { transition: color var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)); }");
178+
expect([rawTimes(bad), rawEases(bad)], "扫描器抓不到裸值动效 ⇒ 上面两条是空真").toEqual([bad, bad]);
179+
expect([rawTimes(good), rawEases(good)], "token + 同值兜底的正例被判成犯规 ⇒ 上面两条会假红").toEqual([[], []]);
180+
// ③a 覆盖面(类序**逐序数组相等**:带 `ed-btn` 会掉出那条回执,少一个 = 覆盖面缩水)
181+
render(<TopBar {...base} />);
182+
const classes = [...screen.getByTestId("topbar").querySelectorAll("button")].map((b) => b.className);
183+
expect(
184+
classes,
185+
"顶栏按钮的类序变了:带 `ed-btn` ⇒ 掉出 `button:not(.ed-btn)` 的元素级回执(也就掉出 reduced-motion 名单)",
186+
).toEqual([...NAV_ENTRIES.map(() => "ed-topbar__tab"), "ed-topbar__action", "ed-topbar__action"]);
187+
// ③b 真源侧:回执存在、声明了时长/缓动、且在**唯一**的 reduced-motion 名单里
188+
const at = MOTION_CSS.indexOf("button:not(.ed-btn)");
189+
expect(at, "motion.css 里找不到 `button:not(.ed-btn)`(顶栏元素级回执的真源)").toBeGreaterThan(-1);
190+
expect(MOTION_CSS.slice(at, MOTION_CSS.indexOf("}", at)), "那条回执没有声明时长/缓动 ⇒ 顶栏的回执是空壳").toContain("transition-duration:");
191+
const reducedAt = MOTION_CSS.indexOf("@media (prefers-reduced-motion");
192+
expect(reducedAt, "motion.css 里找不到 reduced-motion 块").toBeGreaterThan(-1);
193+
expect(MOTION_CSS.slice(reducedAt), "名单没覆盖 `button:not(.ed-btn)` ⇒ 顶栏在 reduced-motion 下照旧动").toContain("button:not(.ed-btn)");
194+
expect(CSS, "TopBar.css 自写了 reduced-motion 块 ⇒ app/src 出现第二条(唯一块的真源在 motion.css)").not.toContain("@media (prefers-reduced-motion");
143195
});
144196

145197
it("③ 高度消费 --ed-nav-h(不许再写 56)", () => {

‎app/src/shell/TopBar.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
* 边界:本组件**不**知道采集状态、也不渲染 AI toast —— 采集徽标/对话面板入口经 `right` 插槽注入,
2020
* AI toast 不在顶栏、也不由本组件渲染:它是 App.tsx 的 `MainShell` 最外层一个 fixed 覆盖层
2121
* (控制方裁决 A3:它是 1024 溢出的唯一主因,单项 373.75 px = 视口的 36.5%),见 App.tsx 该处注释。
22-
* 批 3 不加任何动效(`TopBar.css` 里禁止 transition/animation,由本文件的测试断言)。
22+
* 批 3 不加任何动效;批 6 T14 起 `TopBar.css` 的动效禁令**改判为条件式**(只许 token + 落点必须
23+
* 留在 `motion.css` 元素级回执的覆盖面内;`@keyframes` 与第二条 reduced-motion 块仍禁),
24+
* 由本文件的同名测试断言(T14 一并更正这条过时标签)。
2325
*/
2426
import type { ReactNode } from "react";
2527
import { Icon, type IconName } from "../ui/icons";

0 commit comments

Comments
 (0)