|
| 1 | +/** |
| 2 | + * @ai-context proofreadMode.test.ts — 审校模式通道的**纯函数与静态判据**(node 环境,不写 jsdom 头)。 |
| 3 | + * |
| 4 | + * Why node:本文件判的事实恰好与 jsdom 无关 —— ① 属性名与两态的**字面量冻结**;② 写读通道在**注入的 |
| 5 | + * 假宿主**上的往返(含 `off` ⇒ **摘属性**);③ 「无 `document` ⇒ 静默不动、不抛」这条**只能在真 node |
| 6 | + * 环境判**(jsdom 里 `document` 恒存在 ⇒ 那条降级路径不可达);④ `App.tsx` 的第三条 keydown 与两个 |
| 7 | + * 既有出口的源码形态;⑤ `main.tsx` 的 **import 源序**(CSS 覆盖块的生效纪律);⑥ 「不得合并结案」的解耦。 |
| 8 | + * 真 `<html>` 上的挂载 / 切换 / 卸载在 `proofreadMode.dom.test.tsx` —— 两侧互补,都不可删 |
| 9 | + * (先例 = `shellPhase.test.ts` + `shellPhase.dom.test.tsx`)。 |
| 10 | + * |
| 11 | + * 副作用:只读磁盘(`App.tsx` / `main.tsx` / `App.tsx` 的兄弟文件)与临时改写 `globalThis.document` |
| 12 | + * (逐条 `finally` 还原,照 `motion/intensity.test.ts` 的 `withDocument` 范式)。 |
| 13 | + * 边界:① 宿主用**假元素**(只实现 setAttribute / getAttribute / removeAttribute / hasAttribute); |
| 14 | + * ② 🔴 **判不到解算后的墨度**(jsdom 与文本级仪器都不解析 `var()`)⇒ 本文件不写「已升到 ≥4.5:1」 |
| 15 | + * (真解算值归 T12–T15 的 CDP 读数);③ 入口按钮归 T11 ⇒ 「三出口」里的**按钮出口本任务不存在**, |
| 16 | + * 下面只守「本层零原生 `<button>`」这条不冲突的形态。 |
| 17 | + */ |
| 18 | +import { readFileSync } from "node:fs"; |
| 19 | +import { dirname, join } from "node:path"; |
| 20 | +import { fileURLToPath } from "node:url"; |
| 21 | +import { createElement } from "react"; |
| 22 | +import { renderToString } from "react-dom/server"; |
| 23 | +import { describe, expect, it } from "vitest"; |
| 24 | +import { stripComments, walkSources, relOf } from "../ui/primitives/sliceScan"; |
| 25 | +import { PROOFREAD_MODE_ATTR, applyProofreadMode, readProofreadMode, useProofreadMode } from "./proofreadMode"; |
| 26 | +import type { ProofreadMode } from "./proofreadMode"; |
| 27 | + |
| 28 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 29 | +const SRC = join(HERE, ".."); |
| 30 | +const read = (rel: string): string => readFileSync(join(SRC, ...rel.split("/")), "utf8").replace(/\r\n/g, "\n"); |
| 31 | + |
| 32 | +/** 假宿主:写入面 = 属性表(真 DOM 面在 jsdom 文件)。`removeAttribute` / `hasAttribute` 是 V1 的牙。 */ |
| 33 | +function fakeEl(): HTMLElement { |
| 34 | + const attrs = new Map<string, string>(); |
| 35 | + return { |
| 36 | + setAttribute: (k: string, v: string): void => void attrs.set(k, v), |
| 37 | + getAttribute: (k: string): string | null => attrs.get(k) ?? null, |
| 38 | + removeAttribute: (k: string): void => void attrs.delete(k), |
| 39 | + hasAttribute: (k: string): boolean => attrs.has(k), |
| 40 | + } as unknown as HTMLElement; |
| 41 | +} |
| 42 | + |
| 43 | +/** 逐条注入假全局并还原(`Reflect.deleteProperty` 处理「本来就没有」的情形)。 */ |
| 44 | +function withDocument<T>(value: unknown, run: () => T): T { |
| 45 | + const had = Object.getOwnPropertyDescriptor(globalThis, "document"); |
| 46 | + Object.defineProperty(globalThis, "document", { value, configurable: true, writable: true }); |
| 47 | + try { |
| 48 | + return run(); |
| 49 | + } finally { |
| 50 | + if (had) Object.defineProperty(globalThis, "document", had); |
| 51 | + else Reflect.deleteProperty(globalThis, "document"); |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +describe("① 属性名(逐字冻结)与消歧(控制方 §2 B2)", () => { |
| 56 | + it("属性名逐字 data-proofread-mode;且**不得**等于 data-proofread", () => { |
| 57 | + expect(PROOFREAD_MODE_ATTR).toBe("data-proofread-mode"); |
| 58 | + expect(PROOFREAD_MODE_ATTR, "data-proofread 已被 LLM 文本校对占用(控制方 §2 B2)").not.toBe("data-proofread"); |
| 59 | + }); |
| 60 | + |
| 61 | + it("消歧正控:`proofread` 一名的既有占用面确实还在(本通道不占用它)", () => { |
| 62 | + // 域 = 入库的 `app/src` 生产文件(P-34 的入库域口径;测试文件不算占用面) |
| 63 | + const prod = walkSources(SRC) |
| 64 | + .map((abs) => relOf(SRC, abs)) |
| 65 | + .filter((rel) => !/\.test\.tsx?$/.test(rel)); |
| 66 | + const llmSide = prod.filter((rel) => /proofread/i.test(rel)); |
| 67 | + expect(llmSide.length, "LLM 文本校对的占用面读空了 ⇒ 上面的消歧是无的放矢").toBeGreaterThanOrEqual(3); |
| 68 | + // 本通道的实现面里**不得**出现裸 `data-proofread`(除 `-mode` 后缀外) |
| 69 | + const mine = ["shell/proofreadMode.ts"]; |
| 70 | + expect(mine.filter((rel) => /data-proofread(?!-mode)/.test(stripComments(read(rel))))).toEqual([]); |
| 71 | + }); |
| 72 | +}); |
| 73 | + |
| 74 | +describe("② 写通道:`on` 置属性 · `off` **摘属性**(规格 §4.3④「缺省不落属性」)", () => { |
| 75 | + it("退出后必须不落属性:`off` ⇒ hasAttribute === false(**不是**写成 \"off\")", () => { |
| 76 | + const el = fakeEl(); |
| 77 | + applyProofreadMode("on", el); |
| 78 | + expect(el.getAttribute("data-proofread-mode")).toBe("on"); |
| 79 | + expect(el.hasAttribute("data-proofread-mode")).toBe(true); |
| 80 | + applyProofreadMode("off", el); |
| 81 | + expect(el.hasAttribute("data-proofread-mode"), "退出后必须不落属性(写成 \"off\" 会让缺省态与显式关不可分)").toBe(false); |
| 82 | + expect(el.getAttribute("data-proofread-mode")).toBeNull(); |
| 83 | + }); |
| 84 | + |
| 85 | + it("幂等:连写两次 `on` / 连写两次 `off` 的终态与写一次相同", () => { |
| 86 | + const el = fakeEl(); |
| 87 | + applyProofreadMode("on", el); |
| 88 | + applyProofreadMode("on", el); |
| 89 | + expect(el.getAttribute("data-proofread-mode")).toBe("on"); |
| 90 | + applyProofreadMode("off", el); |
| 91 | + applyProofreadMode("off", el); |
| 92 | + expect(el.hasAttribute("data-proofread-mode")).toBe(false); |
| 93 | + }); |
| 94 | + |
| 95 | + it("不传元素 ⇒ 默认宿主是 document.documentElement(`<html>`)", () => { |
| 96 | + const el = fakeEl(); |
| 97 | + withDocument({ documentElement: el }, () => applyProofreadMode("on")); |
| 98 | + expect(el.getAttribute("data-proofread-mode")).toBe("on"); |
| 99 | + }); |
| 100 | + |
| 101 | + it("无 document(真 node 环境)且不传元素 / 传 null ⇒ 静默不动,不抛", () => { |
| 102 | + expect(typeof document).toBe("undefined"); |
| 103 | + expect(() => applyProofreadMode("on")).not.toThrow(); |
| 104 | + expect(() => applyProofreadMode("off", null)).not.toThrow(); |
| 105 | + }); |
| 106 | + |
| 107 | + it("非法值 ⇒ **不动**既有属性(不是回退 off:静默不动优于替调用方猜一个值)", () => { |
| 108 | + const el = fakeEl(); |
| 109 | + applyProofreadMode("on", el); |
| 110 | + applyProofreadMode("yes" as unknown as ProofreadMode, el); |
| 111 | + expect(el.getAttribute("data-proofread-mode"), "非法值把既有属性改掉了").toBe("on"); |
| 112 | + applyProofreadMode("OFF" as unknown as ProofreadMode, el); |
| 113 | + expect(el.hasAttribute("data-proofread-mode"), "大小写不符的值被当成 off 摘了属性").toBe(true); |
| 114 | + }); |
| 115 | +}); |
| 116 | + |
| 117 | +describe("③ 读通道:缺失与垃圾值一律 null", () => { |
| 118 | + it("属性缺失 / 空串 / 旧版遗留值 / 大小写不符 ⇒ null;两个字面量 ⇒ 原样读回", () => { |
| 119 | + const el = fakeEl(); |
| 120 | + expect(readProofreadMode(el)).toBeNull(); |
| 121 | + for (const junk of ["", "true", "ON", "on ", "on;off", "mode"]) { |
| 122 | + el.setAttribute("data-proofread-mode", junk); |
| 123 | + expect(readProofreadMode(el), `垃圾值 ${JSON.stringify(junk)}`).toBeNull(); |
| 124 | + } |
| 125 | + el.setAttribute("data-proofread-mode", "on"); |
| 126 | + expect(readProofreadMode(el)).toBe("on"); |
| 127 | + // `"off"` 也在名册内(守卫只认两个字面量),但**写入侧从不落它**(`off` ⇒ removeAttribute) |
| 128 | + // ⇒ 这个形态只可能来自外部手写 DOM;读回 `"off"` 与「未设置」在调用方等价(都 = 常态)。 |
| 129 | + el.setAttribute("data-proofread-mode", "off"); |
| 130 | + expect(readProofreadMode(el)).toBe("off"); |
| 131 | + }); |
| 132 | + |
| 133 | + it("无 document 且不传元素 ⇒ null,不抛;有宿主时读到的是**真属性**(不是内存副本)", () => { |
| 134 | + expect(typeof document).toBe("undefined"); |
| 135 | + expect(readProofreadMode()).toBeNull(); |
| 136 | + expect(() => readProofreadMode(null)).not.toThrow(); |
| 137 | + const el = fakeEl(); |
| 138 | + applyProofreadMode("on", el); |
| 139 | + expect(readProofreadMode(el)).toBe(el.getAttribute("data-proofread-mode")); |
| 140 | + }); |
| 141 | +}); |
| 142 | + |
| 143 | +describe("④ useProofreadMode 在 node 环境下可调用(SSR 不执行 effect ⇒ 只证「不抛」)", () => { |
| 144 | + it("renderToString 渲染一个调用本 hook 的探针组件 ⇒ 不抛,且 `<html>` 上什么都没写", () => { |
| 145 | + const Probe = (): string => { |
| 146 | + const [, toggle] = useProofreadMode(); |
| 147 | + expect(typeof toggle).toBe("function"); |
| 148 | + return "ok"; |
| 149 | + }; |
| 150 | + expect(typeof document).toBe("undefined"); |
| 151 | + expect(renderToString(createElement(Probe))).toBe("ok"); |
| 152 | + }); |
| 153 | +}); |
| 154 | + |
| 155 | +describe("⑤ App.tsx 的第三条 window 级 keydown(V1 的机器面;两条既有出口逐字未动)", () => { |
| 156 | + const APP = read("App.tsx"); |
| 157 | + |
| 158 | + it("R 组合切换 + Esc 退出的两条分支都在,且 hook 被调用", () => { |
| 159 | + expect(APP, "本层没有接上模式位 hook").toContain("useProofreadMode()"); |
| 160 | + expect( |
| 161 | + /e\.ctrlKey && e\.shiftKey && \(e\.key === "R" \|\| e\.key === "r"\)/.test(APP), |
| 162 | + "缺 ⌘/Ctrl+Shift+R 的判定分支", |
| 163 | + ).toBe(true); |
| 164 | + expect(/e\.key === "Escape" && proofread === "on"/.test(APP), "缺 Esc 退出分支(或没按模式位门控)").toBe(true); |
| 165 | + }); |
| 166 | + |
| 167 | + it("既有两条 keydown 逐字未动(Ctrl+Shift+A / Ctrl+K),且 window 级 keydown 计数恰 3", () => { |
| 168 | + expect(/e\.ctrlKey && e\.shiftKey && \(e\.key === "A" \|\| e\.key === "a"\)/.test(APP), "Ctrl+Shift+A 被改了").toBe(true); |
| 169 | + expect( |
| 170 | + /e\.ctrlKey && !e\.shiftKey && !e\.altKey && \(e\.key === "k" \|\| e\.key === "K"\)/.test(APP), |
| 171 | + "Ctrl+K 被改了(CommandPalette.test.tsx:199 逐字读它)", |
| 172 | + ).toBe(true); |
| 173 | + expect([...APP.matchAll(/window\.addEventListener\("keydown"/g)].length, "本层的 keydown 监听数变了").toBe(3); |
| 174 | + expect([...APP.matchAll(/window\.removeEventListener\("keydown"/g)].length, "解绑数 ≠ 监听数").toBe(3); |
| 175 | + }); |
| 176 | + |
| 177 | + it("本层零原生 `<button>`(按钮出口归 T11 的 TopBar,且必须走 Button 原语)", () => { |
| 178 | + expect(stripComments(APP), "App.tsx 出现原生 <button> ⇒ 撞 FROZEN_NATIVE_BUTTON_BY_FILE").not.toContain("<button"); |
| 179 | + }); |
| 180 | +}); |
| 181 | + |
| 182 | +describe("⑥ 源码顺序(§C53.5 适用点②):main.tsx 的覆盖 CSS 必须在 token CSS **之后**", () => { |
| 183 | + const main = read("main.tsx"); |
| 184 | + const atTokens = main.indexOf('import "./ui/tokens.css";'); |
| 185 | + const atProof = main.indexOf('import "./ui/proofread.css";'); |
| 186 | + |
| 187 | + it("两条 import 都 ≥ 0(反空真)且 token < proofread", () => { |
| 188 | + expect(atTokens, "token CSS 的 import 锚读不到 ⇒ 下面的顺序断言会空真").toBeGreaterThanOrEqual(0); |
| 189 | + expect(atProof, "proofread.css 的 import 锚读不到(接线丢了?)").toBeGreaterThanOrEqual(0); |
| 190 | + expect(atTokens, "覆盖 CSS 排在 token CSS 之前(源序纪律破了)").toBeLessThan(atProof); |
| 191 | + }); |
| 192 | + |
| 193 | + it("仪器自证:同一台读法对**颠倒顺序**的样本必须判否(否则上面那条恒真)", () => { |
| 194 | + const bogus = 'import "./ui/proofread.css";\nimport "./ui/tokens.css";\n'; |
| 195 | + expect(bogus.indexOf('import "./ui/tokens.css";')).toBeGreaterThan(bogus.indexOf('import "./ui/proofread.css";')); |
| 196 | + }); |
| 197 | +}); |
| 198 | + |
| 199 | +describe("⑦ 不得合并结案(规格 §4.3⑦):低置信墨度与审校模式位**互不推出**", () => { |
| 200 | + /** 生产文件(剥注释;测试文件不是实现面)。 */ |
| 201 | + const prod = walkSources(SRC) |
| 202 | + .map((abs) => ({ rel: relOf(SRC, abs), text: stripComments(readFileSync(abs, "utf8")) })) |
| 203 | + .filter((f) => !/\.test\.tsx?$/.test(f.rel)); |
| 204 | + |
| 205 | + it("两面各自活着,且**实现面不相交**(改一边不构成另一边的证据)", () => { |
| 206 | + const lowConfidence = prod.filter((f) => /\blowConfidenceClass\s*\(/.test(f.text)).map((f) => f.rel); |
| 207 | + const modeFace = prod.filter((f) => f.text.includes("PROOFREAD_MODE_ATTR")).map((f) => f.rel); |
| 208 | + // 反空真:两面都必须真的读到东西(否则「互相推不出」是两台空仪器在互相见证) |
| 209 | + expect(lowConfidence.length, "低置信墨度的调用点读法失效(C4.2 面)").toBeGreaterThanOrEqual(2); |
| 210 | + expect(modeFace, "模式位的实现面读法失效").toContain("shell/proofreadMode.ts"); |
| 211 | + // 🔴 解耦断言:两件事的实现面**互不相交** —— 低置信墨度(§4.2 的四档墨度)与审校模式位(§4.3③) |
| 212 | + // 是两条独立的条件,任何一边的存在都**不得**被当成另一边已兑现的证据。 |
| 213 | + expect(modeFace.filter((rel) => lowConfidence.includes(rel)), "模式位的实现面里出现低置信调用点 ⇒ 两条条件被合并结案").toEqual([]); |
| 214 | + expect(lowConfidence.filter((rel) => prod.find((f) => f.rel === rel)!.text.includes("data-proofread-mode")), "低置信面里出现模式位属性 ⇒ 两条条件被合并结案").toEqual([]); |
| 215 | + }); |
| 216 | +}); |
0 commit comments