|
| 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