|
15 | 15 | * `breakpointFor(...)` / `autoFoldBelow` —— 规格与阈值都住在注册表里。⑤ 按**目录发现** |
16 | 16 | * 而非硬列文件名;判据只看**代码行**(丢注释行——注释里提到字段名不是「自建」, |
17 | 17 | * 首版未丢被自己误伤过)。 |
| 18 | + * ⑤ 另有两条**身份**判据(2026-09-12 补,纯新增断言、实现未动):(a) 两个实参的键必须 |
| 19 | + * **同一** —— 只判「同一行同时出现」时 `useColumnLayout("notes-list", |
| 20 | + * columnSpec("notes-groups"))` 判绿;(b) `min===max===0` 的行(`clamp(d,0,0)=0` |
| 21 | + * ⇒ 静默 0 宽,`settings-main` 是其一)不得作为 hook 的键实参 —— 注册表里的 ⚠️ 注释 |
| 22 | + * 不是判据。 |
18 | 23 | * |
19 | 24 | * ⚠️ 仪器局限(诚实边界):本文件**读不到**「组件实际渲染了多宽」—— 那是 jsdom 侧 |
20 | 25 | * `components/notes/NotesReadingColumn.outline.test.tsx`(大纲列接线)与 T14 的像素探针的活。 |
@@ -74,6 +79,44 @@ function hits(hit: RegExp): string[] { |
74 | 79 | return out; |
75 | 80 | } |
76 | 81 |
|
| 82 | +/** ⑤(a) 的捕获器:`useColumnLayout("a", columnSpec("b"))` 的**两个**实参都抓下来。 |
| 83 | + * Why 要升级:只要求同一行同时出现 `useColumnLayout(` 与 `columnSpec(` 时,把某一列的 |
| 84 | + * 规格喂给另一列(`("notes-list", columnSpec("notes-groups"))`)会判绿 —— 2026-09-12 |
| 85 | + * 变异体 A5d 在旧判据下实测绿、在新判据下红。 */ |
| 86 | +const CALL_PAIR = /useColumnLayout\(\s*"([^"]+)"\s*,\s*columnSpec\(\s*"([^"]+)"\s*\)\s*\)/g; |
| 87 | + |
| 88 | +/** ⑤(b) 的捕获器:`useColumnLayout(` 后的**第一个字符串实参**(不要求同行有 `columnSpec` |
| 89 | + * —— 0/0 行即便配了规格也必须红;多行写法也逃不出本正则)。 */ |
| 90 | +const HOOK_KEY = /useColumnLayout\(\s*"([^"]+)"/g; |
| 91 | + |
| 92 | +interface CallPair { |
| 93 | + /** `hits()` 给的 `域路径:行` 前缀(格式固定为 `路径:行<两空格>原文`) */ |
| 94 | + where: string; |
| 95 | + hookKey: string; |
| 96 | + specKey: string; |
| 97 | +} |
| 98 | + |
| 99 | +function whereOf(hit: string): string { |
| 100 | + return hit.slice(0, hit.indexOf(" ")); |
| 101 | +} |
| 102 | + |
| 103 | +function callPairs(): CallPair[] { |
| 104 | + const out: CallPair[] = []; |
| 105 | + for (const hit of hits(/useColumnLayout\(/)) { |
| 106 | + for (const m of hit.matchAll(CALL_PAIR)) { |
| 107 | + out.push({ where: whereOf(hit), hookKey: m[1], specKey: m[2] }); |
| 108 | + } |
| 109 | + } |
| 110 | + return out; |
| 111 | +} |
| 112 | + |
| 113 | +/** ⑤(a) 判据本体 —— 抽成函数是为了让**仪器自检走同一条代码路径**(不是复述判据) */ |
| 114 | +function crossedKeys(pairs: CallPair[]): string[] { |
| 115 | + return pairs |
| 116 | + .filter((p) => p.hookKey !== p.specKey) |
| 117 | + .map((p) => `${p.where} → useColumnLayout("${p.hookKey}", columnSpec("${p.specKey}"))`); |
| 118 | +} |
| 119 | + |
77 | 120 | describe("列注册表(规格 §6.2)", () => { |
78 | 121 | it("① 13 行、键唯一、顺序稳定", () => { |
79 | 122 | expect(COLUMN_SPECS).toHaveLength(13); |
@@ -161,6 +204,45 @@ describe("调用点判据(规格 §6.2「页面不再自建 hook」)", () => |
161 | 204 | expect(bad, `这些调用点仍在自建列规格(应写 columnSpec("<key>")):\n${bad.join("\n")}`).toEqual([]); |
162 | 205 | }); |
163 | 206 |
|
| 207 | + it("⑤ 每个调用点的 hook 键与 columnSpec 键**同一**(捕获并比对,不只判同现)", () => { |
| 208 | + // 仪器自检①:阳性样本(写岔键)必红、阴性样本(合法写法)不误伤 —— 走的是同一条判据 |
| 209 | + expect(crossedKeys([{ where: "<样本>", hookKey: "notes-list", specKey: "notes-groups" }])) |
| 210 | + .toEqual(['<样本> → useColumnLayout("notes-list", columnSpec("notes-groups"))']); |
| 211 | + expect(crossedKeys([{ where: "<样本>", hookKey: "x", specKey: "x" }])).toEqual([]); |
| 212 | + |
| 213 | + const lines = hits(/useColumnLayout\(/); |
| 214 | + // 仪器自检②:捕获必须覆盖**每一个**命中行 —— 否则「实参换成变量 / 换行写法」会让本条静默空转 |
| 215 | + const uncaptured = lines.filter((l) => [...l.matchAll(CALL_PAIR)].length === 0); |
| 216 | + expect(uncaptured, `这些调用点逃出了捕获(本条会静默空转):\n${uncaptured.join("\n")}`).toEqual([]); |
| 217 | + |
| 218 | + const pairs = callPairs(); |
| 219 | + expect(pairs.length, "捕获到 0 个调用点 ⇒ 本条空转").toBeGreaterThan(3); |
| 220 | + const bad = crossedKeys(pairs); |
| 221 | + expect(bad, `这些调用点把某一列的规格喂给了另一列:\n${bad.join("\n")}`).toEqual([]); |
| 222 | + }); |
| 223 | + |
| 224 | + it("⑤ 0/0 行(clamp 恒得 0 ⇒ 静默 0 宽)不得喂给 useColumnLayout", () => { |
| 225 | + // Why 按**形状**(min===max===0)而非点名 `settings-main`:陷阱的成因是 `clamp(d,0,0)=0`, |
| 226 | + // 任何 0/0 行都一样。注册表 `columnRegistry.ts` 对该行只有 ⚠️ 注释 —— 注释不是判据: |
| 227 | + // 2026-09-12 变异体 M1a(SettingsPage 写 `useColumnLayout("settings-main", …)`)在旧判据下 |
| 228 | + // 判绿、在本条下红。 |
| 229 | + const zeroSpan: string[] = COLUMN_SPECS.filter((c) => c.min === 0 && c.max === 0).map((c) => c.key); |
| 230 | + expect(zeroSpan.length, "0/0 行集合为空 ⇒ 本条会静默空转").toBeGreaterThan(2); |
| 231 | + // 形状判据必须仍然覆盖 §6.2「居中 860」那一行(本条正是因它而写) |
| 232 | + expect(zeroSpan).toContain("settings-main"); |
| 233 | + |
| 234 | + const fed = (line: string) => |
| 235 | + [...line.matchAll(HOOK_KEY)].map((m) => m[1]).filter((k) => zeroSpan.includes(k)); |
| 236 | + // 仪器自检:阳性样本必命中、阴性样本不误伤(与正式判据同一个函数) |
| 237 | + expect(fed('const c = useColumnLayout("settings-main", columnSpec("settings-main"));')).toEqual(["settings-main"]); |
| 238 | + expect(fed('const c = useColumnLayout("notes-outline", columnSpec("notes-outline"));')).toEqual([]); |
| 239 | + |
| 240 | + const bad = hits(/useColumnLayout\(/).flatMap((h) => |
| 241 | + fed(h).map((k) => `${whereOf(h)} → useColumnLayout("${k}", …)(min 0 / max 0 ⇒ clamp 得 0)`), |
| 242 | + ); |
| 243 | + expect(bad, `这些 0/0 行不得喂给 useColumnLayout:\n${bad.join("\n")}`).toEqual([]); |
| 244 | + }); |
| 245 | + |
164 | 246 | it("⑤ 扫描域内不得再出现断点阈值(阈值住在注册表 / breakpoints)", () => { |
165 | 247 | const bad = hits(/breakpointFor\(|autoFoldBelow/); |
166 | 248 | expect(bad, `这些文件仍在自建阈值:\n${bad.join("\n")}`).toEqual([]); |
|
0 commit comments