Skip to content

Commit 5d485ff

Browse files
committed
test(ui): pin button migration and ratchet down
1 parent 0e17e66 commit 5d485ff

4 files changed

Lines changed: 508 additions & 59 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// @vitest-environment node
2+
/**
3+
* @ai-context **T12 迁移判据**(批 4 B4 的落点):把 `const *Btn*` 常量族的直接消费者
4+
* (`style={xxxBtn}` / `style={xxxBtn(...)}`)换成 `Button` 原语后,**这些形态必须归零、且
5+
* 换出来的 `Button` 形状必须逐文件与冻结表一致**。
6+
*
7+
* Why 需要它:棘轮(`nativeButton.ratchet.test.ts`)只看得见「<button> 变少了」,看不见
8+
* 「**换成了什么**」—— 把 `variant="primary"` 写成 `"secondary"` 在棘轮下**完全无声**。
9+
* 本文件补的正是这条:`文件 × (variant,size)` 形状表 + 常量族台账 + **缺口登记**(还没迁的
10+
* 文件逐条列名 + 实测处数,不许静默消失)。
11+
*
12+
* ★ 形状表**不锚行号**:批 4 的 T13–T17 会继续编辑同一批文件 —— 行号锚点会让「在我上面加一行
13+
* import」变成假红。按**文件**聚合的计数粒度既抓得住「某处换了 variant」(计数必变),
14+
* 又不会被无关编辑误伤。**逐处 `文件:行` 的三元组对拍**由同一提交的 `tmp/t12/verify-map.mjs`
15+
* 对 `tmp/t12/button-map.md` 做(交付物 side,读数见报告 §5)。
16+
*
17+
* ★ 扫描口径 = `./buttonScan`(唯一实现,与生成器共享;本文件不重写剥注释状态机)。
18+
*
19+
* 副作用:只读磁盘(37 个源文件),不修改任何文件。边界:
20+
* ① **不判视觉**(jsdom / 文本层判不了观感;像素归批 8);
21+
* ② 常量族白名单是**显式**的 —— 少一条 / 多一条都红(防「看起来干净」的静默残留);
22+
* ③ 缺口表是**实测值**(不是"应该还剩几处")—— 后来者补做时它会红,那正是补做的信号。
23+
*/
24+
import { readFileSync } from "node:fs";
25+
import { dirname, join } from "node:path";
26+
import { fileURLToPath } from "node:url";
27+
import { describe, expect, it } from "vitest";
28+
import { FROZEN_NATIVE_BUTTON_TOTAL } from "./nativeButtonBaseline";
29+
import { declsOf, directHits, shapeCensus, stripComments } from "./buttonScan";
30+
31+
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
32+
33+
/** T1 `tmp/t1/callsites.txt` §B 的 37 文件(B4 的「真实迁移面」清单,逐字冻结) */
34+
const T1_SECTION_B_37: readonly string[] = [
35+
"components/AiProviderSettings.tsx",
36+
"components/AiRefineCard.tsx",
37+
"components/AiServicePanel.tsx",
38+
"components/AiTaskPanel.tsx",
39+
"components/AsrConfusionPanel.tsx",
40+
"components/CaptureFloatPanel.tsx",
41+
"components/EnrichPanel.tsx",
42+
"components/GoalPlanApprovalDialog.tsx",
43+
"components/GraduateDialog.tsx",
44+
"components/GroupRowContextMenu.tsx",
45+
"components/ImageGallery.tsx",
46+
"components/InterviewDialog.tsx",
47+
"components/KnowledgeTreeView.tsx",
48+
"components/LinkEntityPicker.tsx",
49+
"components/MaterialInputPanel.tsx",
50+
"components/ModelCardFromNoteDialog.tsx",
51+
"components/ModelManagementPanel.tsx",
52+
"components/NoteAiDialog.tsx",
53+
"components/PracticeQuestionsOverlays.tsx",
54+
"components/ProofreadPanel.tsx",
55+
"components/RefineLaunchDialog.tsx",
56+
"components/RefineStrategyPicker.tsx",
57+
"components/RichEditorView.tsx",
58+
"components/SecondPassPanel.tsx",
59+
"components/SessionListRow.tsx",
60+
"components/SessionSearchBar.tsx",
61+
"components/SessionSelectionToolbar.tsx",
62+
"components/SopRunOverlay.tsx",
63+
"components/StructureImageSection.tsx",
64+
"components/VideoImportPanel.tsx",
65+
"components/VocabManager.tsx",
66+
"components/WebArticleView.tsx",
67+
"components/WebImportPanel.tsx",
68+
"components/WebInboxPanel.tsx",
69+
"components/action-center/ActionCenterPanel.tsx",
70+
"components/session-detail/SessionDetailHeader.tsx",
71+
"pages/GoalsPage.tsx",
72+
];
73+
74+
/** 登记例外:**点名 + 理由**(缺口必须可见;不许从账本消失) */
75+
const EXCLUDED: Readonly<Record<string, string>> = {
76+
"components/AiProviderSettings.tsx": "与 T11 并行撞车(同一文件同时被两个任务点名)",
77+
"components/GroupRowContextMenu.tsx": "T8 的 dialogMigration.e.test.ts:240-244 禁止 NON_MIGRATED_14 import 原语(B1)",
78+
"components/RichEditorView.tsx": "同上(B1 锚定菜单)",
79+
};
80+
const MIGRATED = T1_SECTION_B_37.filter((f) => EXCLUDED[f] === undefined);
81+
82+
/** 缺口:这些文件**还剩几处** `style={xxxBtn}`(实测值;补做时这里先红) */
83+
const GAP_SITES: Readonly<Record<string, number>> = {
84+
"components/AiProviderSettings.tsx": 8,
85+
"components/GroupRowContextMenu.tsx": 2,
86+
"components/RichEditorView.tsx": 3,
87+
};
88+
89+
/** 仍被引用的 `const *Btn*` 白名单(`文件|常量名`)—— **只有 spread 消费者**的常量按兵不动 */
90+
const STILL_REFERENCED: readonly string[] = [
91+
"components/AiRefineCard.tsx|btn",
92+
"components/AiServicePanel.tsx|btn",
93+
"components/AiTaskPanel.tsx|btn",
94+
"components/AsrConfusionPanel.tsx|rowBtn",
95+
"components/CaptureFloatPanel.tsx|btn",
96+
"components/CaptureFloatPanel.tsx|iconBtn",
97+
"components/EnrichPanel.tsx|btn",
98+
"components/InterviewDialog.tsx|ghostBtn",
99+
"components/MaterialInputPanel.tsx|btn",
100+
"components/NoteAiDialog.tsx|menuBtn",
101+
"components/PracticeQuestionsOverlays.tsx|btn",
102+
"components/PracticeQuestionsOverlays.tsx|ghostBtn",
103+
"components/ProofreadPanel.tsx|btn",
104+
"components/ProofreadPanel.tsx|ghostBtn",
105+
"components/ProofreadPanel.tsx|okBtn",
106+
"components/RefineLaunchDialog.tsx|btn",
107+
"components/RefineStrategyPicker.tsx|optBtn",
108+
"components/SecondPassPanel.tsx|btn",
109+
"components/SecondPassPanel.tsx|ghostBtn",
110+
"components/SecondPassPanel.tsx|okBtn",
111+
"components/SessionSelectionToolbar.tsx|btn",
112+
"components/SopRunOverlay.tsx|btn",
113+
"components/SopRunOverlay.tsx|ghostBtn",
114+
"components/VideoImportPanel.tsx|btn",
115+
"components/VocabManager.tsx|btn",
116+
"components/WebArticleView.tsx|btn",
117+
"components/WebInboxPanel.tsx|btn",
118+
"components/WebInboxPanel.tsx|ghostBtn",
119+
"components/action-center/ActionCenterPanel.tsx|btn",
120+
"components/action-center/ActionCenterPanel.tsx|dangerBtn",
121+
"components/action-center/ActionCenterPanel.tsx|ghostBtn",
122+
"components/action-center/ActionCenterPanel.tsx|okBtn",
123+
"components/session-detail/SessionDetailHeader.tsx|btn",
124+
"pages/GoalsPage.tsx|ghostBtn",
125+
];
126+
127+
/** 换出来的 `Button` 形状表:`文件 → (variant,size) 形状 → 计数`(迁移后实测) */
128+
const BUTTON_SHAPES: Readonly<Record<string, Readonly<Record<string, number>>>> = {
129+
"components/AiRefineCard.tsx": { "variant=secondary size=md": 1 },
130+
"components/AiServicePanel.tsx": { "variant=secondary size=md": 1 },
131+
"components/AiTaskPanel.tsx": { "variant=secondary size=sm": 3 },
132+
"components/AsrConfusionPanel.tsx": { "variant=secondary size=md": 1 },
133+
"components/CaptureFloatPanel.tsx": { "variant=ghost size=sm": 5 },
134+
"components/EnrichPanel.tsx": { "variant=secondary size=md": 4 },
135+
"components/GoalPlanApprovalDialog.tsx": { "variant=secondary size=md": 2, "variant=primary size=md": 1 },
136+
"components/GraduateDialog.tsx": { "variant=primary size=md": 2, "variant=secondary size=md": 1 },
137+
"components/ImageGallery.tsx": { "variant=secondary size=md": 1 },
138+
"components/InterviewDialog.tsx": { "variant=secondary size=md": 2, "variant=primary size=md": 2 },
139+
"components/KnowledgeTreeView.tsx": { "variant=secondary size=md": 9 },
140+
"components/LinkEntityPicker.tsx": { "variant=三元:ghost|primary size=md": 1 },
141+
"components/MaterialInputPanel.tsx": { "variant=secondary size=lg": 2 },
142+
"components/ModelCardFromNoteDialog.tsx": { "variant=secondary size=md": 2 },
143+
"components/ModelManagementPanel.tsx": { "variant=secondary size=sm": 2 },
144+
"components/NoteAiDialog.tsx": { "variant=secondary size=lg": 1 },
145+
"components/PracticeQuestionsOverlays.tsx": { "variant=primary size=md": 4, "variant=secondary size=md": 1 },
146+
"components/ProofreadPanel.tsx": { "variant=primary size=md": 1 },
147+
"components/RefineLaunchDialog.tsx": { "variant=secondary size=md": 2 },
148+
"components/RefineStrategyPicker.tsx": { "variant=三元:primary|secondary size=md": 1, "variant=三元:primary|secondary size=sm": 1 },
149+
"components/SecondPassPanel.tsx": { "variant=primary size=md": 1, "variant=secondary size=md": 2 },
150+
"components/SessionListRow.tsx": { "variant=secondary size=sm": 2 },
151+
"components/SessionSearchBar.tsx": { "variant=三元:ghost|primary size=sm": 3, "variant=secondary size=md": 2 },
152+
"components/SessionSelectionToolbar.tsx": { "variant=三元:primary|secondary size=sm": 1 },
153+
"components/SopRunOverlay.tsx": { "variant=secondary size=md": 2, "variant=primary size=md": 4 },
154+
"components/StructureImageSection.tsx": { "variant=secondary size=md": 2 },
155+
"components/VideoImportPanel.tsx": { "variant=secondary size=lg": 1 },
156+
"components/VocabManager.tsx": { "variant=secondary size=sm": 4 },
157+
"components/WebArticleView.tsx": { "variant=secondary size=md": 1 },
158+
"components/WebImportPanel.tsx": { "variant=primary size=md": 1 },
159+
"components/WebInboxPanel.tsx": { "variant=primary size=md": 1, "variant=secondary size=md": 1 },
160+
"components/action-center/ActionCenterPanel.tsx": { "variant=primary size=md": 4, "variant=secondary size=md": 3 },
161+
"components/session-detail/SessionDetailHeader.tsx": { "variant=secondary size=md": 1 },
162+
"pages/GoalsPage.tsx": { "variant=primary size=md": 1, "variant=secondary size=md": 1 },
163+
};
164+
/** 形状表覆盖的 `Button` 总数(= 迁移处数;棘轮的 Δ 必须与它逐字相等) */
165+
const MIGRATED_SITES = 91;
166+
/** 迁移前读数(`dev@bde807dc` 导出树,同一仪器实测):棘轮基线**只许在这条线以下** */
167+
const PRE_T12_NATIVE_BUTTONS = 493;
168+
169+
const read = (rel: string): string => stripComments(readFileSync(join(SRC, ...rel.split("/")), "utf8"));
170+
171+
describe("T12 · 仪器自证(正/负对照 · 剥注释 · 空真排查)", () => {
172+
it("directHits 命中已知形态 · 对无意义串报 0 · **剥注释生效** · 带参形态同样命中", () => {
173+
const sample = [
174+
"const okBtn: React.CSSProperties = { color: \"#fff\" };",
175+
"export function X() {",
176+
" return <button style={okBtn} onClick={() => void f()}>保存</button>;",
177+
"}",
178+
].join("\n");
179+
expect(directHits(sample), "阳性对照:已知 style={okBtn} 必须被命中").toBe(1);
180+
expect(directHits(sample.replace("style={okBtn}", "style={other}")), "阴性对照:非常量引用报 0").toBe(0);
181+
expect(directHits(sample.replace("style={okBtn}", "style={{ ...okBtn, color: \"red\" }}")), "spread 形态**不算**直接形态(B4 只迁直接形态)").toBe(0);
182+
expect(directHits("const s = 1;"), "无意义串报 0").toBe(0);
183+
expect(directHits(stripComments("// <button style={okBtn}>\n")), "行注释里的标签不算命中(剥注释承重)").toBe(0);
184+
expect(directHits(stripComments("/* <button style={okBtn}> */\n")), "块注释同理").toBe(0);
185+
expect(directHits(stripComments(sample + "\nconst u = \"https://example.com/a//b\";\n")), "URL 字面量不破坏剥注释(\"//\" 在字符串里)").toBe(1);
186+
expect(directHits(sample.replace("style={okBtn}", "style={okBtn(1)}")), "带参形态 style={okBtn(1)} 命中").toBe(1);
187+
});
188+
189+
it("shapeCensus 能区分字面量 / 三元 / 缺省(否则形状表是空真)", () => {
190+
expect(shapeCensus('<Button variant="primary" size="sm">x</Button>')).toEqual({ "variant=primary size=sm": 1 });
191+
expect(shapeCensus('<Button variant={a ? "ghost" : "primary"} size="md">x</Button>')).toEqual({ "variant=三元:ghost|primary size=md": 1 });
192+
expect(shapeCensus("<Button>x</Button>")).toEqual({ "variant=(默认) size=(默认)": 1 });
193+
expect(shapeCensus('<Text variant="primary">x</Text>'), "非 Button 标签不进形状表").toEqual({});
194+
});
195+
196+
it("域完备性双侧自证:37 条清单每条在盘上可读 · 清单/例外/缺口三张表互相对得上", () => {
197+
expect(T1_SECTION_B_37.length, "T1 §B 清单长度不是 37").toBe(37);
198+
expect(new Set(T1_SECTION_B_37).size, "清单有重复条目").toBe(37);
199+
expect(MIGRATED.length + Object.keys(EXCLUDED).length).toBe(37);
200+
for (const rel of T1_SECTION_B_37) expect(read(rel).length, `读不到 ${rel}`).toBeGreaterThan(0);
201+
expect(Object.keys(GAP_SITES).sort(), "缺口表与例外表不是同一组文件").toEqual(Object.keys(EXCLUDED).sort());
202+
expect(MIGRATED.length, "已迁文件数不像话(清单读错会在这里显形)").toBe(34);
203+
});
204+
});
205+
206+
describe("① 直接形态 style={xxxBtn} 在 34 个已迁文件里归零", () => {
207+
it("逐文件 directHits === 0;缺口文件的实测处数 == 登记值(仪器在缺口上有非零读数 ⇒ 不是看不见)", () => {
208+
const bad = MIGRATED.filter((rel) => directHits(read(rel)) !== 0)
209+
.map((rel) => `${rel}: ${directHits(read(rel))}`);
210+
expect(bad, `以下文件仍有直接形态常量消费者:\n${bad.join("\n")}`).toEqual([]);
211+
const nonZero = MIGRATED.filter((rel) => directHits(read(rel)) > 0).length;
212+
expect(nonZero, "已迁文件里还有非零命中(上面那条应已列出)").toBe(0);
213+
for (const [rel, frozen] of Object.entries(GAP_SITES)) {
214+
expect(directHits(read(rel)), `缺口文件 ${rel} 的处数与登记不符(被静默补掉 / 悄悄变大)`).toBe(frozen);
215+
}
216+
});
217+
});
218+
219+
describe("② 常量族台账:孤立常量 = 0,仍被引用者 == 白名单", () => {
220+
it("没有声明了却没人用的 const *Btn*;散见形态逐条在白名单里", () => {
221+
const orphans: string[] = [];
222+
const kept: string[] = [];
223+
for (const rel of MIGRATED) {
224+
const s = read(rel);
225+
for (const name of declsOf(s)) {
226+
const refs = (s.match(new RegExp(`\\b${name}\\b`, "g")) ?? []).length;
227+
if (refs <= 1) orphans.push(`${rel}|${name}`);
228+
else kept.push(`${rel}|${name}`);
229+
}
230+
}
231+
expect(orphans, `孤立常量(声明后无人引用):\n${orphans.join("\n")}`).toEqual([]);
232+
expect(kept.slice().sort(), "仍被引用的常量集与白名单不符(多一条 = 残留没登记,少一条 = 白名单过期)").toEqual([...STILL_REFERENCED]);
233+
});
234+
});
235+
236+
describe("③ Button 形状表:换出来的原语必须与冻结的 (variant,size) 逐文件一致", () => {
237+
it("逐文件形状计数相等,且总数 == 91(把 primary 写成 secondary 这里必红)", () => {
238+
const diffs: string[] = [];
239+
let total = 0;
240+
for (const rel of MIGRATED) {
241+
const now = shapeCensus(read(rel));
242+
total += Object.values(now).reduce((a, b) => a + b, 0);
243+
const want = BUTTON_SHAPES[rel];
244+
if (JSON.stringify(now) !== JSON.stringify(want)) diffs.push(`${rel}: 盘上 ${JSON.stringify(now)} ≠ 冻结 ${JSON.stringify(want)}`);
245+
}
246+
expect(diffs, `形状表不符:\n${diffs.join("\n")}`).toEqual([]);
247+
expect(total, "迁移处数与冻结总数不符(少迁 / 多迁都要红)").toBe(MIGRATED_SITES);
248+
expect(Object.keys(BUTTON_SHAPES).length, "形状表文件数 ≠ 已迁文件数").toBe(MIGRATED.length);
249+
});
250+
});
251+
252+
describe("④ 棘轮朝向 + 缺口可见(这条判据的牙)", () => {
253+
it("棘轮基线在 T12 之前那条线以下(手改回 510 / 抬到 520 这里红)", () => {
254+
expect(FROZEN_NATIVE_BUTTON_TOTAL, `棘轮基线被抬高到 ${FROZEN_NATIVE_BUTTON_TOTAL}(T12 迁移前是 ${PRE_T12_NATIVE_BUTTONS})`).toBeLessThanOrEqual(PRE_T12_NATIVE_BUTTONS);
255+
});
256+
257+
it("两个 B1 例外文件不许 import 原语(与 T8 的 dialogMigration.e.test.ts:240-244 同向复核)", () => {
258+
const offenders = ["components/GroupRowContextMenu.tsx", "components/RichEditorView.tsx"]
259+
.filter((rel) => read(rel).includes("ui/primitives"));
260+
expect(offenders, `B1 例外文件开始用原语了(会绕过 T8 的 NON_MIGRATED_14 守卫):\n${offenders.join("\n")}`).toEqual([]);
261+
});
262+
});

0 commit comments

Comments
 (0)