Skip to content

Commit 357ab11

Browse files
committed
test(primitives): freeze native button ratchet
1 parent e490124 commit 357ab11

2 files changed

Lines changed: 424 additions & 0 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// @vitest-environment node
2+
/**
3+
* @ai-context **原生按钮棘轮**(批 4 B4 裁决的落点):**只许减少,不许新增**。
4+
*
5+
* Why 需要这条守卫:B4 裁定 `Button` 的范围 = `*Btn*` 常量族 + 本批新代码 + **棘轮**,理由是
6+
* 全仓 **510 处 / 121 文件**原生 `<button>` 单批迁不完(且 121 个文件里 77 个无同名测试)。
7+
* 没有棘轮,「永远迁不完」就会退化成「永远在迁」:新代码继续写原生按钮,而没有任何判据说话。
8+
*
9+
* Why 判据是「计数」而不是「逐行原文」(与 `ui/zIndex.guard.test.ts` 的口径**刻意不同**):
10+
* 本批有**拆件**任务(B7:`ChatPage.tsx` 599/600 必须先拆)⇒ 逐行 key 会因为「同一行搬到了
11+
* 新文件」从「命中」变成「新增」而**假红**。计数口径下,搬运由 `SPLIT_MOVES` 显式登记守恒。
12+
*
13+
* ★ 扫描口径(与 `tmp/classify.mjs` 的 `buttonTag` **逐字同源**;口径本身是守卫的一部分)
14+
* ① 域 = `app/src/**` 的 `.ts`/`.tsx` 减 `*.test.ts(x)` 减 `ui/primitives/**` 减 `ui/icons/**`。
15+
* 排除原语是因为 `ui/primitives/Button.tsx` **就是**那个 `<button>` 的落点。
16+
* ② **先剥注释**(`//` 与块注释抹为等长空白);字符串/模板字面量**只跳过、不抹内容**。
17+
* ③ 计「处」= 剥注释后**整段文本**上 `/<button[\s>]/g` 的匹配数(**不是逐行** —— 逐行会把
18+
* 跨行标签漏掉:同一棵树实测逐行 385 vs 整段 510)。
19+
* ④ 不记 `</button>`(不匹配)与 `<Button>`(大小写敏感,不匹配)。
20+
* 已知边界(有意保留、并有用例钉住):字符串字面量里的 `<button` 会被计入 —— 实测本仓 0 例。
21+
*
22+
* 副作用:只读磁盘(递归遍历 `app/src`),不修改任何文件。
23+
* 边界:**批 4 只迁 T12 的 103 行 `style={xxxBtn}`**,余量 ≈ 407 处登记给批 5/7;本守卫冻结余量。
24+
*/
25+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
26+
import { dirname, join, relative, sep } from "node:path";
27+
import { fileURLToPath } from "node:url";
28+
import { describe, expect, it } from "vitest";
29+
import {
30+
FROZEN_BTN_STYLE_CONST_FILES,
31+
FROZEN_BTN_STYLE_CONST_LINES,
32+
FROZEN_NATIVE_BUTTON_BY_FILE,
33+
FROZEN_NATIVE_BUTTON_TOTAL,
34+
SPLIT_MOVES,
35+
} from "./nativeButtonBaseline";
36+
37+
const HERE = dirname(fileURLToPath(import.meta.url));
38+
/** `app/src` —— 基线的键就是相对这个目录的正斜杠路径。 */
39+
const SRC = join(HERE, "..", "..");
40+
41+
/** 计「处」的判据:`<button` 后跟空白或 `>`。`</button>` 与 `<Button>` 均不匹配。 */
42+
const RE_BUTTON = /<button[\s>]/g;
43+
/** `const *Btn* = / :` 样式常量族(与 `classify.mjs` 的 `btnStyleConst` 同一正则)。 */
44+
const RE_BTN_CONST = /const\s+\w*[Bb]tn\w*\s*[:=]/g;
45+
46+
/**
47+
* 剥注释:**与 `tmp/scan-callsites.mjs` 的 `stripComments()` 同一状态机**(刻意重写一份而不是
48+
* import —— 那个文件在 gitignored 的 `tmp/` 下,测试**不能**依赖它存在)。
49+
* 抹成等长空白以保行号;字符串/模板/正则字面量只跳过不抹内容(改口径等于改守卫)。
50+
*/
51+
function stripComments(src: string): string {
52+
const out = src.split("");
53+
const blank = (a: number, b: number): void => {
54+
for (let i = a; i < b; i++) if (out[i] !== "\n") out[i] = " ";
55+
};
56+
const isRegexStart = (k: number): boolean => {
57+
let j = k - 1;
58+
while (j >= 0 && /\s/.test(src.charAt(j))) j--;
59+
if (j < 0) return true;
60+
return "(,=:[!&|?{};+-*%~^<>".includes(src.charAt(j));
61+
};
62+
const n = src.length;
63+
let i = 0;
64+
while (i < n) {
65+
const c = src.charAt(i);
66+
if (c === "/" && src.charAt(i + 1) === "/") {
67+
const end = src.indexOf("\n", i);
68+
blank(i, end < 0 ? n : end);
69+
i = end < 0 ? n : end;
70+
continue;
71+
}
72+
if (c === "/" && src.charAt(i + 1) === "*") {
73+
const end = src.indexOf("*/", i + 2);
74+
blank(i, end < 0 ? n : end + 2);
75+
i = end < 0 ? n : end + 2;
76+
continue;
77+
}
78+
if (c === '"' || c === "'") {
79+
let j = i + 1;
80+
while (j < n && src.charAt(j) !== c) {
81+
if (src.charAt(j) === "\\") j++;
82+
if (src.charAt(j) === "\n") break;
83+
j++;
84+
}
85+
i = j + 1;
86+
continue;
87+
}
88+
if (c === "`") {
89+
let j = i + 1;
90+
while (j < n && src.charAt(j) !== "`") {
91+
if (src.charAt(j) === "\\") j++;
92+
j++;
93+
}
94+
i = j + 1;
95+
continue;
96+
}
97+
if (c === "/" && isRegexStart(i)) {
98+
let j = i + 1;
99+
let cls = false;
100+
let ok = false;
101+
while (j < n) {
102+
const d = src.charAt(j);
103+
if (d === "\\") {
104+
j += 2;
105+
continue;
106+
}
107+
if (d === "\n") break;
108+
if (d === "[") cls = true;
109+
else if (d === "]") cls = false;
110+
else if (d === "/" && !cls) {
111+
ok = true;
112+
break;
113+
}
114+
j++;
115+
}
116+
if (ok) {
117+
blank(i + 1, j);
118+
i = j + 1;
119+
continue;
120+
}
121+
}
122+
i++;
123+
}
124+
return out.join("");
125+
}
126+
127+
/** 纯计数(供扫描与仪器自证共用):`new RegExp(re.source,"g")` —— **不是** `eval`。 */
128+
function countOf(text: string, re: RegExp): number {
129+
return (text.match(new RegExp(re.source, "g")) ?? []).length;
130+
}
131+
132+
function walk(dir: string, out: string[] = []): string[] {
133+
for (const name of readdirSync(dir)) {
134+
const p = join(dir, name);
135+
if (statSync(p).isDirectory()) walk(p, out);
136+
else if (/\.(ts|tsx)$/.test(name)) out.push(p);
137+
}
138+
return out;
139+
}
140+
141+
const isTest = (f: string): boolean => /\.test\.(ts|tsx)$/.test(f);
142+
const isPrim = (f: string): boolean => f.includes(`${sep}ui${sep}primitives${sep}`);
143+
const isIcons = (f: string): boolean => f.includes(`${sep}ui${sep}icons${sep}`);
144+
145+
interface Scan {
146+
/** 相对 `app/src` 的正斜杠路径 → `<button` 处数(只含 >0 的文件) */
147+
buttonByFile: Map<string, number>;
148+
buttonTotal: number;
149+
btnConstLines: number;
150+
btnConstFiles: number;
151+
}
152+
153+
function scan(): Scan {
154+
const buttonByFile = new Map<string, number>();
155+
let buttonTotal = 0;
156+
let btnConstLines = 0;
157+
let btnConstFiles = 0;
158+
for (const f of walk(SRC)) {
159+
if (isTest(f) || isPrim(f) || isIcons(f)) continue;
160+
const stripped = stripComments(readFileSync(f, "utf8"));
161+
const rel = relative(SRC, f).split(sep).join("/");
162+
const n = countOf(stripped, RE_BUTTON);
163+
if (n > 0) {
164+
buttonByFile.set(rel, n);
165+
buttonTotal += n;
166+
}
167+
const c = countOf(stripped, RE_BTN_CONST);
168+
if (c > 0) {
169+
btnConstLines += c;
170+
btnConstFiles += 1;
171+
}
172+
}
173+
return { buttonByFile, buttonTotal, btnConstLines, btnConstFiles };
174+
}
175+
176+
const SCAN = scan();
177+
const BASELINE = FROZEN_NATIVE_BUTTON_BY_FILE;
178+
const SPLIT_TARGETS = new Set(SPLIT_MOVES.map((m) => m.split("|")[0]));
179+
180+
describe("原生按钮棘轮(B4):冻结 510 处 / 121 文件,只许减少", () => {
181+
it("仪器自证:能命中已知存在的串 · 对无意义串报 0 · 且剥注释生效(末条是**已知边界**)", () => {
182+
expect(countOf("const a = <button onClick={f}>x</button>;", RE_BUTTON)).toBe(1);
183+
expect(countOf("const a = <butto n />;", RE_BUTTON)).toBe(0);
184+
// 剥注释:整行注释与块注释里的标签都不算命中(仓内已多次注释误伤)
185+
expect(countOf(stripComments("// <button style={x}>\n"), RE_BUTTON)).toBe(0);
186+
expect(countOf(stripComments("/* <button style={x}> */\n"), RE_BUTTON)).toBe(0);
187+
// 已知边界(有意保留):字符串字面量里的标签**仍会被计入** —— 实测本仓 0 例
188+
expect(countOf(stripComments('const s = "<button>";\n'), RE_BUTTON)).toBe(1);
189+
// 真实文件阳性对照:本文件被排除,另点一个**域内**且已知含按钮的文件
190+
expect(SCAN.buttonByFile.get("components/ImageGallery.tsx") ?? 0).toBeGreaterThan(0);
191+
expect(BASELINE["components/ImageGallery.tsx"]).toBeGreaterThan(0);
192+
});
193+
194+
it("① 总量只减不增(`<button` 处数 ≤ 冻结基线)", () => {
195+
expect(
196+
SCAN.buttonTotal,
197+
`全仓原生 \`<button>\` 已从基线 ${FROZEN_NATIVE_BUTTON_TOTAL} 涨到 ${SCAN.buttonTotal} —— 新代码请用 \`Button\`(barrel: ui/primitives)`,
198+
).toBeLessThanOrEqual(FROZEN_NATIVE_BUTTON_TOTAL);
199+
});
200+
201+
it("② 逐文件只减不增(拆分面回潮就是这里红)", () => {
202+
const regressions: string[] = [];
203+
for (const [file, cur] of SCAN.buttonByFile) {
204+
const base = BASELINE[file];
205+
if (base === undefined) {
206+
if (!SPLIT_TARGETS.has(file)) regressions.push(`${file}: 新增文件带 ${cur} 处,基线里没有(拆件请登记 SPLIT_MOVES)`);
207+
continue;
208+
}
209+
if (cur > base) regressions.push(`${file}: ${base} → ${cur}(+${cur - base})`);
210+
}
211+
expect(regressions, `以下文件的原生按钮数超过冻结基线:\n${regressions.join("\n")}`).toEqual([]);
212+
});
213+
214+
it("③ 基线无僵尸键(清单里的每个文件都还在扫描域内存在)", () => {
215+
const zombies: string[] = [];
216+
for (const file of Object.keys(BASELINE)) {
217+
const abs = join(SRC, file);
218+
// 用 `existsSync` 而不是 `statSync(...).isFile()`:本仓的 `@types/node` 版本下
219+
// `Stats.isFile` 在 tsc 下报 TS2339(同 `columnRegistry.test.ts:43` 记的 withFileTypes 缺口)。
220+
if (!existsSync(abs)) zombies.push(`${file}: 文件不存在`);
221+
else if (isTest(abs) || isPrim(abs) || isIcons(abs)) zombies.push(`${file}: 已移出扫描域`);
222+
}
223+
expect(zombies, `基线里的键已失效(拆件/改名后请更新基线):\n${zombies.join("\n")}`).toEqual([]);
224+
});
225+
226+
it("④ 拆件搬运守恒(`SPLIT_MOVES` 的每条 `新文件|源文件` 两侧之和 == 基线源键)", () => {
227+
const broken: string[] = [];
228+
const seen = new Set<string>();
229+
for (const move of SPLIT_MOVES) {
230+
const parts = move.split("|");
231+
if (parts.length !== 2 || !parts[0] || !parts[1] || parts[0] === parts[1]) {
232+
broken.push(`${move}: 形态必须是 "新文件|源文件"(两段非空且不同)`);
233+
continue;
234+
}
235+
const [target, source] = parts;
236+
if (seen.has(target)) broken.push(`${target}: 同一个新文件登记了多次搬运`);
237+
seen.add(target);
238+
const sourceBase = BASELINE[source];
239+
if (sourceBase === undefined) {
240+
broken.push(`${move}: 源文件 ${source} 不在基线里`);
241+
continue;
242+
}
243+
if (BASELINE[target] !== undefined) broken.push(`${move}: 目标 ${target} 已在基线里(它只能是拆件产物)`);
244+
const targetNow = SCAN.buttonByFile.get(target) ?? 0;
245+
const sourceNow = SCAN.buttonByFile.get(source) ?? 0;
246+
if (targetNow + sourceNow !== sourceBase) {
247+
broken.push(`${move}: 搬运不守恒 —— ${target}(${targetNow}) + ${source}(${sourceNow}) ≠ 基线 ${sourceBase}`);
248+
}
249+
}
250+
expect(broken, `SPLIT_MOVES 登记有误:\n${broken.join("\n")}`).toEqual([]);
251+
});
252+
253+
it("⑤ `const *Btn*` 样式常量族只减不增(行数与文件数双口径)", () => {
254+
expect(
255+
SCAN.btnConstLines,
256+
`\`const *Btn*\` 样式常量的行数已从 ${FROZEN_BTN_STYLE_CONST_LINES} 涨到 ${SCAN.btnConstLines} —— 新按钮请用 \`Button\`,别再声明局部样式常量`,
257+
).toBeLessThanOrEqual(FROZEN_BTN_STYLE_CONST_LINES);
258+
expect(
259+
SCAN.btnConstFiles,
260+
`带 \`const *Btn*\` 的文件数已从 ${FROZEN_BTN_STYLE_CONST_FILES} 涨到 ${SCAN.btnConstFiles}`,
261+
).toBeLessThanOrEqual(FROZEN_BTN_STYLE_CONST_FILES);
262+
});
263+
});

0 commit comments

Comments
 (0)