Skip to content

Commit 32effc9

Browse files
committed
test(motion): 补 token 名形态计数守卫
1 parent b1534d3 commit 32effc9

2 files changed

Lines changed: 91 additions & 5 deletions

File tree

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,23 @@ const CSS_FILES: readonly string[] = readdirSync(PRIMITIVES).filter((f) => f.end
4242
* `--ed-dur-*` · `--ed-ease` · `--ed-ease-instrument` · `--ed-ease-paper`。
4343
* ⚠️ 写成**转义**形态(`var\(`)⇒ `motionTokens.consumption.test.ts` 的全树扫描不会把本文件当消费点
4444
* (本文件头注边界②)。
45+
*
46+
* 🔴 **名字字符集含数字 / 大写 / 下划线**(T13b 修,合并评审 I-1 的上送项):T13 只统一了「四类名」的口径,
47+
* `[a-z-]` 仍把 `--ed-dur-micro2` / `--ed-ease-instrument2` / `--ed-ease-Instrument` 三形态**全部漏掉**
48+
* (终止符前瞻 `(?=[\s,)])` 回溯到底也不成立,T13b 实测三形态解析器命中数 = 0)。真源名册今天全是
49+
* 小写连字符 ⇒ 无现实影响;但将来真源引入带数字 / 大写的 name 时,**整条消费点会静默漏检**。
50+
*/
51+
const DURATION_VALUE = new RegExp(String.raw`^var\((--ed-dur-[A-Za-z0-9-]+),\s*(\d+ms)\)$`);
52+
const EASING_VALUE = new RegExp(String.raw`^var\((--ed-ease(?:-[A-Za-z0-9-]+)?),\s*(cubic-bezier\([^)]*\))\)$`);
53+
/**
54+
* **形态计数**用的名字模式(T13b 追加):与上面两台解析器**独立**,字符集再放宽到下划线。
55+
* Why 需要它:两台解析器只看得见 `transition-duration` / `transition-timing-function` 两个属性的**值**
56+
* ⇒ 一处落在别的属性(`transition` 简写 / `transition-delay`)里的 token 消费**完全不被看见**(静默);
57+
* 名字带数字 / 大写时也只是以笼统的「形态不符」面貌出现。本模式只做**计数**:响应层里每处
58+
* `var(--ed-dur-*` / `var(--ed-ease*` 都必须被解析器吃下 —— 数得到却认不出 ⇒ 当场红。
59+
* ⚠️ 写成**转义**形态(`var\(`)⇒ 本文件不会被 `motionTokens.consumption.test.ts` 当成消费点(边界②)。
4560
*/
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\([^)]*\))\)$`);
61+
const TOKEN_SHAPE = /var\(--ed-(?:dur-|ease)[A-Za-z0-9_-]*(?=[\s,)])/g;
4862

4963
describe("⑤ 三档同行为 + 桶边界 + 单一真源(§8.5「节能档只留响应层」)", () => {
5064
it("响应层节里零 `[data-motion`,且全仓没有任何 `[data-motion]` 规则碰这些元素", () => {
@@ -94,19 +108,47 @@ describe("⑤ 三档同行为 + 桶边界 + 单一真源(§8.5「节能档只
94108
if (dur !== null) {
95109
const m = DURATION_VALUE.exec(dur);
96110
if (!m) bad.push(`${r.selector}: transition-duration 不是「token + 同值兜底」形态 → ${dur}`);
111+
else if (!truth.has(m[1])) bad.push(`${r.selector}: ${m[1]} 是死兜底(真源名册里没有这个 token 名)`);
97112
else if (truth.get(m[1]) !== m[2]) bad.push(`${r.selector}: ${m[1]} 兜底 ${m[2]} ≠ 真源 ${truth.get(m[1])}`);
98113
}
99114
const ease = declarationValue(r.body, "transition-timing-function");
100115
if (ease !== null) {
101116
const m = EASING_VALUE.exec(ease);
102117
if (!m) bad.push(`${r.selector}: transition-timing-function 不是「token + 同值兜底」形态 → ${ease}`);
118+
else if (!truth.has(m[1])) bad.push(`${r.selector}: ${m[1]} 是死兜底(真源名册里没有这个 token 名)`);
103119
else if (truth.get(m[1]) !== m[2]) bad.push(`${r.selector}: ${m[1]} 兜底 ${m[2]} ≠ 真源 ${truth.get(m[1])}`);
104120
}
105121
}
106122
expect(bad, `响应层的时长 / 缓动脱离了唯一真源:\n${bad.join("\n")}`).toEqual([]);
107123
expect(truth.size, "真源名册读空了 ⇒ 上面两条是空真").toBe(DURATION_TOKENS.length + EASING_TOKENS.length);
108124
});
109125

126+
/* T13b 追加(合并评审 I-1 的「形态计数」建议):上面那台解析器只看得见**两个属性的值**
127+
* ⇒ ① 名字带数字 / 大写时它以笼统的「形态不符」出现(实测:三形态解析命中数 = 0);
128+
* ② 落在别的属性(`transition` 简写 / `transition-delay`)里的 token 消费**连看都不看**(静默)。
129+
* 这条把「数得到却认不出」变成显式红:数目是数得出来的,吃不下就不许过。 */
130+
it("T13b 形态计数:响应层每处 `var(--ed-dur-*` / `var(--ed-ease*` 都必须被解析器吃下", () => {
131+
const shaped = [...SECTION.matchAll(TOKEN_SHAPE)].map((m) => m[0]);
132+
expect(SECTION.length, "响应层节读空了 ⇒ 本条是空真").toBeGreaterThan(500);
133+
expect(shaped.length, "响应层一处 token 消费都没有 ⇒ 本条是空真").toBeGreaterThan(0);
134+
// 「解析成功」按**声明体去重**计:`RULES` 的逗号选择器已展开,逐条计会把同一处数成多次
135+
let parsed = 0;
136+
for (const body of new Set(RULES.map((r) => r.body))) {
137+
for (const prop of ["transition-duration", "transition-timing-function"]) {
138+
const v = declarationValue(body, prop);
139+
if (v !== null && (DURATION_VALUE.test(v) || EASING_VALUE.test(v))) parsed += 1;
140+
}
141+
}
142+
expect(
143+
shaped.length,
144+
`响应层有 ${shaped.length} 处 token 消费点,两台解析器只吃下 ${parsed} 处 ⇒ 名字带数字 / 大写、或落在别的属性里的消费点被**静默跳过**:\n${shaped.join("\n")}`,
145+
).toBe(parsed);
146+
// 双侧自证(防空真):宽字符集必须真能数到数字 / 大写名,而 T13b 之前的窄字符集一个都数不到
147+
const nextGen = ["var" + "(--ed-dur-micro2, 120ms)", "var" + "(--ed-ease-Instrument, cubic-bezier(0.4, 0, 0.2, 1))"].join("; ");
148+
expect([...nextGen.matchAll(TOKEN_SHAPE)].length, "形态计数看不见数字 / 大写的名字 ⇒ 它比它看守的解析器还弱").toBe(2);
149+
expect([...nextGen.matchAll(/var\(--ed-(?:dur-|ease)[a-z-]*(?=[\s,)])/g)].length, "窄字符集(T13b 之前的口径)本就数不到这两个名字 —— 这就是盲区本身").toBe(0);
150+
});
151+
110152
/* T13 追加(控制方 2026-09-13 指令):「两条正则只认旧名」是与 `motionTokens.consumption.test.ts`
111153
* 被 T6b 修掉的那条**同一类**缺陷(`--ed-ease` 前缀吞掉 `--ed-ease-<name>`)⇒ 口径本身要有自证。
112154
* 样本一律**拼接写**:本文件不得出现 `var(--ed-` 的裸串(见文件头边界②)。 */

‎app/src/ui/primitives/motionTokens.consumption.test.ts‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
* `ease` 后接 `-` 处正是词边界(`e` 是词字符、`-` 不是)⇒ `var(--ed-ease-instrument` 会被**前缀吞成**
2121
* `--ed-ease`,`fallbackAfter` 随后读到 `-instrument` 就返回 `null` ⇒ ② 把一处**写了兜底**的消费
2222
* 报成「没有兜底」(**误红**)。今天该名 0 消费点所以没触发,波 B/C 一加消费点就炸 ⇒ 名字必须
23-
* **整段吃完**(`ease(?:-[a-z-]+)?`)并以终止符前瞻收尾(`var()` 里名字后只能是空白 / 逗号 / 右括号)。
23+
* **整段吃完**(`ease(?:-[A-Za-z0-9-]+)?`)并以终止符前瞻收尾(`var()` 里名字后只能是空白 / 逗号 / 右括号)。
24+
* ⑤ 🔴 **数字 / 大写也是判据**(T13b 修,合并评审 I-1):`[a-z-]` 会把
25+
* `var(--ed-dur-micro2` / `var(--ed-ease-instrument2` / `var(--ed-ease-Instrument` 整条**静默跳过**
26+
* (前瞻回溯到底也不成立)⇒ 该消费点连 ②③④ 都看不见(实测 **exit 0 · 7/7 全绿**)。**静默不可见
27+
* 比误红更坏**(误红会被发现)⇒ 名字字符集含数字 / 大写,并另加 **⑦ 形态计数**做显式出口。
2428
*/
2529
import { readdirSync, readFileSync, statSync } from "node:fs";
2630
import { dirname, join } from "node:path";
@@ -38,10 +42,20 @@ const SELF = "ui/primitives/motionTokens.consumption.test.ts";
3842
* 消费点:`var(--ed-dur-<name>` / `var(--ed-ease[-<name>]`。
3943
* 🔴 **不许**改回 `\b` 收尾(T8 实测):`ease` 后接 `-` 处正是词边界 ⇒ `var(--ed-ease-instrument`
4044
* 被前缀吞成 `--ed-ease`、兜底解析成 `null`、② 误报「没有兜底」。名字必须整段吃完 + 终止符前瞻。
45+
* 🔴 名字字符集含**数字 / 大写**(T13b 修,见文件头 ⑤):`[a-z-]` 会让带数字 / 大写的新名
46+
* **整条静默不可见**(②③④ 全不触发)—— 那比误红更坏。
4147
*/
42-
const CONSUMER = /var\(--ed-(?:dur-[a-z-]+|ease(?:-[a-z-]+)?)(?=[\s,)])/g;
48+
const CONSUMER = /var\(--ed-(?:dur-[A-Za-z0-9-]+|ease(?:-[A-Za-z0-9-]+)?)(?=[\s,)])/g;
4349
/** 带逗号的消费(与 `CONSUMER` **独立**的形态计数:兜底换了写法也不会被解析器静默漏掉) */
44-
const WITH_COMMA = /var\(--ed-(?:dur-[a-z-]+|ease(?:-[a-z-]+)?)(?=\s*,)/g;
50+
const WITH_COMMA = /var\(--ed-(?:dur-[A-Za-z0-9-]+|ease(?:-[A-Za-z0-9-]+)?)(?=\s*,)/g;
51+
/**
52+
* **形态计数**用的名字模式(T13b 追加,⑦):与 `CONSUMER` **独立**,字符集再放宽到下划线。
53+
* 判据 = 「域里每处这个形态的命中都必须被 `CONSUMER` 认到」—— 数得到却认不出 ⇒ 当场红(不再静默)。
54+
* ⚠️ **不能**用评审建议的逐字形态 `var\(--ed-[a-z0-9-]+`:那是**全族**(含 `var(--ed-ink-1)` /
55+
* `var(--ed-space-2)` …),与消费点数天然不等(T13b 实测:该形态 **338** 处 vs 消费点 **83** 处)
56+
* ⇒ 照抄会把本判据变成**常红**。家族前缀(`dur-` / `ease`)是必需的收窄。
57+
*/
58+
const TOKEN_SHAPE = /var\(--ed-(?:dur-|ease)[A-Za-z0-9_-]*(?=[\s,)])/g;
4559
/** 真源 = **生成器导出**(不是产物)⇒「真源改值而兜底不改」必红(R13.2 的第三条变异体) */
4660
const TRUTH = new Map<string, string>([
4761
...DURATION_TOKENS.map((t) => [`--ed-dur-${t.name}`, `${t.ms}ms`] as const),
@@ -148,6 +162,19 @@ describe("动效 token 消费点:兜底字面量必须等于生成器真源(
148162
"cubic-bezier(0.215, 0.61, 0.355, 1)",
149163
]);
150164
expect([...sample.matchAll(WITH_COMMA)].length, "形态计数(带逗号)必须同样认得 `ease-<name>`").toBe(4);
165+
// T13b 追加:**数字 / 大写**的名字也必须整段被吃下 —— 漏掉时该消费点连 ②③④ 都看不见(静默,比误红更坏)
166+
const nextGen: ReadonlyArray<readonly [string, string, string]> = [
167+
["var" + "(--ed-dur-micro2, 120ms)", "--ed-dur-micro2", "120ms"],
168+
["var" + "(--ed-ease-instrument2, cubic-bezier(0.4, 0, 0.2, 1))", "--ed-ease-instrument2", "cubic-bezier(0.4, 0, 0.2, 1)"],
169+
["var" + "(--ed-ease-Instrument, cubic-bezier(0.4, 0, 0.2, 1))", "--ed-ease-Instrument", "cubic-bezier(0.4, 0, 0.2, 1)"],
170+
];
171+
for (const [value, name, fallback] of nextGen) {
172+
const one = [...value.matchAll(CONSUMER)].map((m) => ({
173+
name: m[0].replace(/^var\(/, ""),
174+
fallback: fallbackAfter(value, (m.index ?? 0) + m[0].length),
175+
}));
176+
expect(one, `数字 / 大写的名字必须被**整段**吃下(否则该消费点整条静默不可见):${value}`).toEqual([{ name, fallback }]);
177+
}
151178
});
152179

153180
it("② 兜底齐备:每个消费点都带兜底(漏一个 ⇒ 那一处静默失去时长)", () => {
@@ -183,4 +210,21 @@ describe("动效 token 消费点:兜底字面量必须等于生成器真源(
183210
expect([...text.matchAll(CONSUMER)].length, "域内 0 命中 ⇒ 是域选错了,不是判据绿").toBeGreaterThan(0);
184211
expect([...text.matchAll(/var\(--ed-zzz-not-a-token\b/g)].length, "无意义串也命中 ⇒ 这台仪器在乱报").toBe(0);
185212
});
213+
214+
/* T13b 追加(合并评审 I-1「形态计数」建议):`CONSUMER` 与 `WITH_COMMA` 共用同一套名字字符集,
215+
* 于是「名字带数字 / 大写」时**整条消费点对所有判据都不可见**(实测 exit 0 · 7/7 全绿)。
216+
* 这条用一台**独立**的、字符集更宽的仪器去数:数得到却认不出 ⇒ 红。 */
217+
it("⑦ 形态计数(T13b):域里每处 `var(--ed-dur-*` / `var(--ed-ease*` 都必须被 `CONSUMER` 吃下", () => {
218+
const text = DOMAIN.map((rel) => maskComments(readFileSync(join(SRC, rel), "utf8"), rel.endsWith(".css"))).join("\n");
219+
const shaped = [...text.matchAll(TOKEN_SHAPE)].map((m) => m[0]);
220+
expect(shaped.length, "域里一处 token 消费都没有 ⇒ 本条是空真").toBeGreaterThan(0);
221+
expect(
222+
shaped.length,
223+
`域里有 ${shaped.length} 处 token 消费,\`CONSUMER\` 只认到 ${HITS.length} 处 ⇒ 名字带数字 / 大写 / 下划线的消费点**整条静默不可见**(②③④ 都不触发):\n${shaped.join("\n")}`,
224+
).toBe(HITS.length);
225+
// 双侧自证(防空真):宽字符集必须真能数到数字 / 大写名,而 T13b 之前的窄字符集一个都数不到
226+
const nextGen = ["var" + "(--ed-dur-micro2, 120ms)", "var" + "(--ed-ease-Instrument, cubic-bezier(0.4, 0, 0.2, 1))"].join("; ");
227+
expect([...nextGen.matchAll(TOKEN_SHAPE)].length, "形态计数看不见数字 / 大写的名字 ⇒ 它比它看守的解析器还弱").toBe(2);
228+
expect([...nextGen.matchAll(/var\(--ed-(?:dur-|ease)[a-z-]*(?=[\s,)])/g)].length, "窄字符集(T13b 之前的口径)本就数不到这两个名字 —— 这就是盲区本身").toBe(0);
229+
});
186230
});

0 commit comments

Comments
 (0)