Skip to content

Commit 5467a91

Browse files
committed
fix(motion): 修响应层守卫的缓动名与important口径
1 parent 129b819 commit 5467a91

1 file changed

Lines changed: 64 additions & 2 deletions

File tree

‎app/src/motion/responseSeams.test.ts‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ const MOTION_CSS = readPrimitiveCss("motion.css");
3232
const REDUCED_BODY = reducedMotionRules()[0].body;
3333
const CSS_FILES: readonly string[] = readdirSync(PRIMITIVES).filter((f) => f.endsWith(".css"));
3434

35+
/**
36+
* 时长 / 缓动消费点的**名字模式**(T13 修:与 `motionTokens.consumption.test.ts` 的 `CONSUMER` 同类缺陷)。
37+
*
38+
* Why:`--ed-ease` 后接 `-` 处正是**词边界** ⇒ 只认 `--ed-ease`(或用 `\b` 收尾)会把
39+
* `--ed-ease-instrument` / `--ed-ease-paper` **前缀吞成** `--ed-ease`,随后因「兜底形态不符」把一处
40+
* **完全合规**的消费点报成违规 —— 波 B/C 第一次用新缓动名时就会误红(双基调的两条缓动 T8 已进真源,
41+
* 今天 0 CSS 消费点,所以这条一直是潜伏的)。四类名字都要覆盖:
42+
* `--ed-dur-*` · `--ed-ease` · `--ed-ease-instrument` · `--ed-ease-paper`。
43+
* ⚠️ 写成**转义**形态(`var\(`)⇒ `motionTokens.consumption.test.ts` 的全树扫描不会把本文件当消费点
44+
* (本文件头注边界②)。
45+
*/
46+
const DURATION_VALUE = new RegExp(String.raw`^var\((--ed-dur-[a-z-]+),\s*(\d+ms)\)$`);
47+
const EASING_VALUE = new RegExp(String.raw`^var\((--ed-ease(?:-[a-z-]+)?),\s*(cubic-bezier\([^)]*\))\)$`);
48+
3549
describe("⑤ 三档同行为 + 桶边界 + 单一真源(§8.5「节能档只留响应层」)", () => {
3650
it("响应层节里零 `[data-motion`,且全仓没有任何 `[data-motion]` 规则碰这些元素", () => {
3751
expect(SECTION).not.toContain("data-motion");
@@ -78,20 +92,49 @@ describe("⑤ 三档同行为 + 桶边界 + 单一真源(§8.5「节能档只
7892
for (const r of RULES) {
7993
const dur = declarationValue(r.body, "transition-duration");
8094
if (dur !== null) {
81-
const m = /^var\((--ed-dur-[a-z-]+),\s*(\d+ms)\)$/.exec(dur);
95+
const m = DURATION_VALUE.exec(dur);
8296
if (!m) bad.push(`${r.selector}: transition-duration 不是「token + 同值兜底」形态 → ${dur}`);
8397
else if (truth.get(m[1]) !== m[2]) bad.push(`${r.selector}: ${m[1]} 兜底 ${m[2]} ≠ 真源 ${truth.get(m[1])}`);
8498
}
8599
const ease = declarationValue(r.body, "transition-timing-function");
86100
if (ease !== null) {
87-
const m = /^var\((--ed-ease),\s*(cubic-bezier\([^)]*\))\)$/.exec(ease);
101+
const m = EASING_VALUE.exec(ease);
88102
if (!m) bad.push(`${r.selector}: transition-timing-function 不是「token + 同值兜底」形态 → ${ease}`);
89103
else if (truth.get(m[1]) !== m[2]) bad.push(`${r.selector}: ${m[1]} 兜底 ${m[2]} ≠ 真源 ${truth.get(m[1])}`);
90104
}
91105
}
92106
expect(bad, `响应层的时长 / 缓动脱离了唯一真源:\n${bad.join("\n")}`).toEqual([]);
93107
expect(truth.size, "真源名册读空了 ⇒ 上面两条是空真").toBe(DURATION_TOKENS.length + EASING_TOKENS.length);
94108
});
109+
110+
/* T13 追加(控制方 2026-09-13 指令):「两条正则只认旧名」是与 `motionTokens.consumption.test.ts`
111+
* 被 T6b 修掉的那条**同一类**缺陷(`--ed-ease` 前缀吞掉 `--ed-ease-<name>`)⇒ 口径本身要有自证。
112+
* 样本一律**拼接写**:本文件不得出现 `var(--ed-` 的裸串(见文件头边界②)。 */
113+
it("口径自证:四类 token 名都能被**整段**解析(`ease-…` 不得被前缀吞成 `--ed-ease` / 不得误判形态)", () => {
114+
const durSamples: ReadonlyArray<readonly [string, string, string]> = [
115+
["var" + "(--ed-dur-micro, 120ms)", "--ed-dur-micro", "120ms"],
116+
["var" + "(--ed-dur-card, 220ms)", "--ed-dur-card", "220ms"],
117+
];
118+
for (const [value, name, fallback] of durSamples) {
119+
const m = DURATION_VALUE.exec(value);
120+
expect([m?.[1], m?.[2]], `时长消费点被漏解析 / 名字被截断:${value}`).toEqual([name, fallback]);
121+
}
122+
const easeSamples: ReadonlyArray<readonly [string, string, string]> = [
123+
["var" + "(--ed-ease, cubic-bezier(0.2, 0, 0, 1))", "--ed-ease", "cubic-bezier(0.2, 0, 0, 1)"],
124+
["var" + "(--ed-ease-instrument, cubic-bezier(0.4, 0, 0.2, 1))", "--ed-ease-instrument", "cubic-bezier(0.4, 0, 0.2, 1)"],
125+
["var" + "(--ed-ease-paper, cubic-bezier(0.215, 0.61, 0.355, 1))", "--ed-ease-paper", "cubic-bezier(0.215, 0.61, 0.355, 1)"],
126+
];
127+
for (const [value, name, fallback] of easeSamples) {
128+
const m = EASING_VALUE.exec(value);
129+
// 名字必须**整段**取到(前缀吞掉时 m[1] 会是 `--ed-ease`、m 直接为 null 也在这里现形)
130+
expect([m?.[1], m?.[2]], `缓动消费点被漏解析 / 名字被前缀吞掉:${value}`).toEqual([name, fallback]);
131+
}
132+
// 反例(防空真):形态不符的写法必须**不**被放过 —— 无兜底 / 兜底类型错位 / 值里带别的写法
133+
for (const badValue of ["var" + "(--ed-ease)", "var" + "(--ed-ease, 120ms)", "var" + "(--ed-dur-micro, cubic-bezier(0.2, 0, 0, 1))"]) {
134+
const ok = DURATION_VALUE.test(badValue) || EASING_VALUE.test(badValue);
135+
expect(ok, `这台解析器把不合规的写法也放过了:${badValue}`).toBe(false);
136+
}
137+
});
95138
});
96139

97140
describe("⑥ 控制方 2026-09-13 追加(T6 评审 I-2 / I-3):优先级机理与两块隔离", () => {
@@ -106,6 +149,25 @@ describe("⑥ 控制方 2026-09-13 追加(T6 评审 I-2 / I-3):优先级
106149
expect(tierBodies.join(" "), "档位块带了 !important ⇒ 反转无障碍优先级").not.toContain("!important");
107150
});
108151

152+
/* T13 追加(控制方 2026-09-13 第二次追加 B):R18.1 的 `!important` 判据只管**档位块**与 reduced 块,
153+
* 响应层节自己「零 `!important`」今天没有判据 —— 有人往里加一条 `!important` 就会反转
154+
* 「行内样式优先于类规则」这条 ADR-033 §4 的既成语义(`motion.css` 头部 ⑤ 逐字),而全仓无人察觉。 */
155+
it("I-4:响应层节**零** `!important`(防反转「行内优先于类」的语义)", () => {
156+
const flagged = (text: string): string[] =>
157+
[...text.matchAll(/([a-z-]+)\s*:\s*([^;{}]*!important[^;{}]*)/g)].map((m) => `${m[1]}: ${m[2].trim()}`);
158+
expect(SECTION.length, "响应层节读空了 ⇒ 本条是空真").toBeGreaterThan(500);
159+
expect(
160+
flagged(SECTION),
161+
`响应层出现了 !important ⇒ 行内样式再也盖不过类规则(ADR-033 §4 的「行内优先于类」被反转):\n${flagged(SECTION).join("\n")}`,
162+
).toEqual([]);
163+
// 反例对照(防空真):同一台扫描器对已知样本必须报满,对干净样本必须 0
164+
expect(flagged(".x { opacity: .5 !important; transition-duration: 1ms !important; }")).toEqual([
165+
"opacity: .5 !important",
166+
"transition-duration: 1ms !important",
167+
]);
168+
expect(flagged(".x { opacity: .5; transition-duration: 1ms; }")).toEqual([]);
169+
});
170+
109171
it("I-3:档位块与 reduced 块之间零规则块(两块必须相邻)", () => {
110172
const tierAt = MOTION_CSS.lastIndexOf("[data-motion=");
111173
const reducedAt = MOTION_CSS.indexOf(REDUCED_AT);

0 commit comments

Comments
 (0)