Skip to content

Commit 4dc3280

Browse files
committed
test(motion): 补齐 T14 守卫的前缀与裸缓动覆盖面
1 parent 56303c6 commit 4dc3280

4 files changed

Lines changed: 42 additions & 7 deletions

File tree

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ const APP = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "Ap
3737
const MOTION_CSS = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "ui", "primitives", "motion.css"), "utf8")
3838
.replace(/\r\n/g, "\n").replace(/\/\*[\s\S]*?\*\//g, "");
3939
const MOTION_REDUCED = MOTION_CSS.slice(MOTION_CSS.indexOf("@media (prefers-reduced-motion"));
40-
/** G2 改判的扫描口径:一条行内 `style` 里**没走 token** 的 `transition|animation` 声明(空数组 = 都走 token) */
40+
/** G2 改判的扫描口径(T14c 加固,Minor M-7):旧口径是**包含式** —— 一条混写(`opacity var(--ed-dur-toast-in, 180ms) 5s`)含任一 token 即放行,与 G1 的排除式强度不等。新口径 = 「有 token **且** 剥掉 token 后无裸值」;`!hasMotionToken` 那一支保留旧口径的强度(无 token 的声明照旧判红)。 */
41+
const hasMotionToken = (d: string): boolean => /var\(--ed-(?:dur|ease)/.test(d);
42+
/** 剥掉 `var(--ed-x, <同值兜底>)` 后剩下的**裸**时长/缓动(含裸关键字 `linear`/`ease`/`steps()`;边界用 `(?<![\w-])…(?![\w-])` 以免误判无兜底的 `var(--ed-ease)`) */
43+
const rawTiming = (d: string): boolean => {
44+
const rest = d.replace(/var\(--ed-[a-z0-9-]+,[^)]*\)/g, "");
45+
return /\d+(?:\.\d+)?\s*(?:ms|s)\b|cubic-bezier|ease-(?:in|out|in-out)\b|(?<![\w-])(?:linear|ease|steps)(?![\w-])/i.test(rest);
46+
};
4147
const inlineMotionViolations = (style: string): string[] =>
42-
style.split(";").filter((d) => /(?:transition|animation)/.test(d) && !/var\(--ed-(?:dur|ease)/.test(d));
48+
style.split(";").filter((d) => /(?:transition|animation)/.test(d) && (!hasMotionToken(d) || rawTiming(d)));
4349
/** 模拟懒 chunk 加载失败(渲染期抛错 ⇒ React 落到最近的上层边界);抛错时 React 仍会 console.error ⇒ 抑制后还原 */
4450
function Boom(): never { throw new Error("模拟懒 chunk 加载失败"); }
4551
/**
@@ -88,6 +94,13 @@ describe("首访加载态 ShellFallback(批 2 未做 #6)", () => {
8894
"transition: opacity 150ms",
8995
]);
9096
expect(inlineMotionViolations("transition: opacity var(--ed-dur-toast-in, 180ms)"), "token 正例被判成犯规 ⇒ 上面会假红").toEqual([]);
97+
// T14c 加固的自证(M-7):**混写**(含 token + 裸值)必须恰 1 条 —— 旧包含式口径在这里会放行
98+
expect(inlineMotionViolations("transition: opacity var(--ed-dur-toast-in, 180ms) 5s"), "混写被包含式口径放行 ⇒ M-7 的洞还在").toEqual([
99+
"transition: opacity var(--ed-dur-toast-in, 180ms) 5s",
100+
]);
101+
expect(inlineMotionViolations("transition: opacity var(--ed-dur-toast-in, 180ms) linear"), "token 后的裸缓动关键字未被剥离式判据抓到").toEqual([
102+
"transition: opacity var(--ed-dur-toast-in, 180ms) linear",
103+
]);
91104
// ② 无条件的那一半:动效载体是类,且基类在唯一的 reduced-motion 名单里
92105
const bases = [...new Set((el.className || "").split(/\s+/).filter(Boolean).map((c) => c.split("--")[0]))];
93106
expect(bases.length, "加载态根元素一个类都没有 ⇒ 下面的名单判据是空真").toBeGreaterThan(0);

‎app/src/shell/TopBar.css‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* Why 需要这个文件(现网 App.tsx 的顶栏是内联 style):两档溢出是**媒体查询**的活,内联 style
55
* 表达不了。动效(墨渍 hover / 按下微陷 / 焦点环落纸)批 3 时**属批 6**;批 6 T14 起按 R1.2 的
66
* 到期改判**解禁为条件式** —— 本文件**可以**写 transition / animation,但三个条件必须同时成立:
7-
* ① 时长与缓动只许 `var(--ed-dur-*)` / `var(--ed-ease*)`(裸 ms/s 与裸曲线一律红);
7+
* ① 时长与缓动只许 `var(--ed-dur-*)` / `var(--ed-ease*)`(裸 ms/s、裸曲线与裸缓动关键字
8+
* `linear`/`ease`/`steps()` 一律红;属性名认**厂商前缀 / 大写**写法 —— T14c 按 R1.2 补齐);
89
* ② 落点必须落在 `motion.css` 的**元素级回执**覆盖面内(顶栏交互元素全是裸 `<button>` ⇒ 由
910
* `button:not(.ed-btn)` 承担)⇒ 本文件**不自写**第二条 = 不造第二个真源(R1.1);
1011
* ③ **仍不得**出现 `@keyframes`(§8.2 桶边界:keyframes 只留 Loading/Skeleton/Probe),也**不得**

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,15 @@ const PICTO = /\p{Extended_Pictographic}/u;
7777
* `withoutTokenVars` 必须先剥掉 `var(--ed-x, <同值兜底>)` 整段:本仓的 token 习惯写法**带同值兜底**
7878
* (`--ed-dur-micro, 120ms`)⇒ 不剥的话「不得写裸 ms/s」会把合法 token 用法判成犯规(**假红**,
7979
* 逼实施者绕开守卫 —— 计划 Task 14 的 G1 新断言原文就有这个洞,T14 实施时实测并修正)。
80+
* 🔴 R1.2(改判不得弱于原判据):属性名还认**厂商前缀 / 大写**(同 `motion-coverage.test.ts:91` 的孪生守卫)—— 旧 G1 是子串匹配,`-webkit-transition:` 在它下面是**红**,新口径漏掉前缀支就是强度回退(T14c 专属变异体实测)。
8081
*/
8182
const motionDecls = (css: string): string[] =>
82-
css.split(";").filter((d) => /(?:^|[;\s])(?:transition|animation)(?:-duration|-name)?\s*:/.test(d));
83+
css.split(";").filter((d) => /(?:^|[;\s])(?:-(?:webkit|moz|ms|o)-)?(?:transition|animation)(?:-duration|-name)?\s*:/i.test(d));
8384
const withoutTokenVars = (decl: string): string => decl.replace(/var\(--ed-[a-z0-9-]+,[^)]*\)/g, "");
8485
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+
/** 裸缓动的第三类:**裸关键字** `linear`/`ease`/`steps()`(Important ②)。边界用 `(?<![\w-])…(?![\w-])` 而非 `\b`:后者会把无兜底的合法 token `var(--ed-ease)` 与 `linear-gradient` 误判成犯规。 */
87+
const rawEases = (decls: string[]): string[] =>
88+
decls.filter((d) => /cubic-bezier|ease-(?:in|out|in-out)\b|(?<![\w-])(?:linear|ease|steps)(?![\w-])/i.test(withoutTokenVars(d)));
8689

8790
afterEach(cleanup);
8891

@@ -179,6 +182,12 @@ describe("TopBar(规格 §6.1)", () => {
179182
const good = motionDecls(".a { transition: color var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)); }");
180183
expect([rawTimes(bad), rawEases(bad)], "扫描器抓不到裸值动效 ⇒ 上面两条是空真").toEqual([bad, bad]);
181184
expect([rawTimes(good), rawEases(good)], "token + 同值兜底的正例被判成犯规 ⇒ 上面两条会假红").toEqual([[], []]);
185+
// 前缀 / 大写属性名(R1.2):旧口径的子串匹配挡得住,新口径漏掉前缀支 = 强度回退 ⇒ 两者都必须判红
186+
const pre = motionDecls(".a { -webkit-transition: color 120ms ease-out; }"), up = motionDecls(".a { ANIMATION: ed-x 1s infinite; }");
187+
expect([pre.length, up.length, rawTimes([pre[0]]), rawEases([pre[0]]), rawTimes([up[0]])], "前缀 / 大写属性名逃出扫描器,或裸值未被判红").toEqual([1, 1, [pre[0]], [pre[0]], [up[0]]]);
188+
// 裸缓动关键字(Important ②):三者同样是绕过 token 的写法;末项 = 反向对照(无兜底的合法 token 必须绿)
189+
const bare = [".a { transition: opacity linear 200ms; }", ".a { transition: opacity ease 200ms; }", ".a { transition: opacity steps(4); }"].map((s) => motionDecls(s)[0]);
190+
expect([bare.map((d) => rawEases([d])), rawEases(motionDecls(".a { transition: color var(--ed-dur-micro) var(--ed-ease); }"))], "裸关键字漏判 / 合法无兜底 token 被误判").toEqual([bare.map((d) => [d]), []]);
182191
// ③a 覆盖面(类序**逐序数组相等**:带 `ed-btn` 会掉出那条回执,少一个 = 覆盖面缩水)
183192
render(<TopBar {...base} />);
184193
const classes = [...screen.getByTestId("topbar").querySelectorAll("button")].map((b) => b.className);

‎app/src/ui/primitives/ViewSwitcher.test.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,15 @@ function bodyOf(css: string, sel: string): string {
3737
return at < 0 ? "" : css.slice(at, css.indexOf("}", at));
3838
}
3939

40-
/** W8b(R11.6 追加段)的扫描口径:一段 CSS 里的 `transition` 声明(`;` 切声明、锚属性名) */
41-
const transitionDecls = (css: string): string[] => css.split(";").filter((d) => /(?:^|[;\s])transition\s*:/.test(d));
40+
/**
41+
* W8b(R11.6 追加段)的扫描口径:一段 CSS 里的 `transition` 声明(`;` 切声明、锚属性名)。
42+
* 🔴 T14c 加固(Minor M-8 + R1.2,**只加强**):长写(`-duration` / `-timing-function` / `-delay`)与
43+
* 厂商前缀 / 大写属性名此前都逃出锚点 ⇒ 裸 `transition-duration: 300ms` 曾是**绿**(漏判)。
44+
* ⚠️ 有意**不纳入** `-property`:它不带时长/缓动值,纳入会让合法的 `transition-property: opacity`
45+
* 假红,而 R36④ 的裁决是「判据错杀比漏判更坏」。
46+
*/
47+
const transitionDecls = (css: string): string[] =>
48+
css.split(";").filter((d) => /(?:^|[;\s])(?:-(?:webkit|moz|ms|o)-)?transition(?:-duration|-timing-function|-delay)?\s*:/i.test(d));
4249
/** 其中**没走 token** 的那些(空数组 = 都走 token) */
4350
const untokenedTransitions = (css: string): string[] => transitionDecls(css).filter((d) => !/var\(--ed-(?:dur|ease)/.test(d));
4451

@@ -247,5 +254,10 @@ describe("W8 响应层接缝(§8.6.1 第 3 条):`--ed-dur-micro` 120ms +
247254
"transition: background 120ms",
248255
]);
249256
expect(untokenedTransitions("transition: background var(--ed-dur-micro, 120ms);"), "token 正例被判成犯规 ⇒ 上面会假红").toEqual([]);
257+
// T14c 加固的自证(M-8):裸**长写** / 厂商前缀 / 大写都必须恰 1 条 —— 旧锚点只认 shorthand,三者都静默通过
258+
for (const bad of ["transition-duration: 300ms;", "-webkit-transition: background 120ms;", "TRANSITION: background 120ms;"]) {
259+
expect(untokenedTransitions(bad), `长写 / 前缀 / 大写写法漏判:${bad}`).toEqual([bad.slice(0, -1)]);
260+
}
261+
expect(untokenedTransitions("transition-duration: var(--ed-dur-micro, 120ms);"), "合法 token 长写被判成犯规 ⇒ 会假红").toEqual([]);
250262
});
251263
});

0 commit comments

Comments
 (0)