Skip to content

Commit 783a62a

Browse files
committed
test(shell): 补 index.html 与列键名守卫并加注 ADR-034
1 parent 357ab11 commit 783a62a

3 files changed

Lines changed: 299 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// @vitest-environment node
2+
/**
3+
* @ai-context **列键名冻结**(规格 §6.2 / ADR-034 §2「键名 = 既有持久化键」)—— 只许不改名。
4+
*
5+
* Why 单独立一条**字面量**判据:`useColumnLayout` 把 key 当作 `localStorage` 的持久化键
6+
* (`layout:col-width:{key}` / `layout:col-fold:{key}`,`src/hooks/useColumnLayout.ts:43/53/87/88`)
7+
* ⇒ **改一个 key = 所有用户已记住的列宽/折叠态静默丢失**,而既有判据**抓不到**:
8+
* `columnRegistry.test.ts` ① 只判「13 行 · 键唯一 · 首尾键」—— 把 `notes-list` 改名成
9+
* `notes-list-v2` 在那条判据下**照样绿**(批 3 计划 `:1621` 想写的「键集与持久化键同形」
10+
* 最终没有落库)⇒ T0 报告 §二 · G3 把它列为**风险最高的一条缺口**。
11+
*
12+
* ★ 期望值**逐字写字符串字面量**,**不许**用 `COLUMN_KEYS` / `columnSpec(...)` / 任何被测模块的
13+
* 导出参与构造 —— 本批已实证「自引用 = 永真断言」(期望值与被测值同源 ⇒ 两边一起改照样绿)。
14+
* `COLUMN_KEYS` 只出现在**被测的一侧**(`keyDiff(COLUMN_KEYS)`)。
15+
*
16+
* 口径两条(各自独立可红):
17+
* ① `FROZEN_KEYS` 与 `COLUMN_KEYS` **按下标逐字相等**(长度 + 顺序 + 拼写;顺序 = 规格表行序,
18+
* 重排会让 `COLUMN_SPECS` 的行序漂移);
19+
* ② 持久化键的**前缀**仍是 `layout:col-width:` / `layout:col-fold:` —— 前缀与键名是同一个
20+
* `localStorage` 键的两半,改任一半都让用户的列宽静默丢失(② 是本次为闭合该机制所加,
21+
* T0 报告 §二 · G3 只点名了 ①)。
22+
*
23+
* 副作用:只读 `src/hooks/useColumnLayout.ts`(②),不修改任何文件。
24+
* 边界:本条**不**证明「这 13 个名字 = 历史持久化键」—— 那需要 `git show` 的树外一次性对拍
25+
* (见 T0 报告 §二 · G3 与批 8/T18 的收口核对表);本条是「从今往后不许再漂」的棘轮。
26+
* 也不判宽度/阈值(那住 `columnRegistry.test.ts` ②③④)。跑在 vitest 全局 `node` 环境。
27+
*/
28+
import { readFileSync } from "node:fs";
29+
import { dirname, join } from "node:path";
30+
import { fileURLToPath } from "node:url";
31+
import { describe, expect, it } from "vitest";
32+
import { COLUMN_KEYS } from "./columnRegistry";
33+
34+
/** 冻结清单 = 规格 §6.2 的 13 行键名(**逐字字面量**,顺序 = 规格表行序)。 */
35+
const FROZEN_KEYS: readonly string[] = [
36+
"classroom-left",
37+
"classroom-right",
38+
"sessions-list",
39+
"notes-groups",
40+
"notes-list",
41+
"notes-outline",
42+
"action-main",
43+
"review-main",
44+
"chat-sidebar",
45+
"knowledge-left",
46+
"knowledge-detail",
47+
"goals-left",
48+
"settings-main",
49+
];
50+
51+
/** ① 的判据本体(抽成函数 ⇒ 阴性样本走**同一条**代码路径,而不是复述一遍期望值)。 */
52+
function keyDiff(actual: readonly string[]): string[] {
53+
const out: string[] = [];
54+
const n = Math.max(actual.length, FROZEN_KEYS.length);
55+
for (let i = 0; i < n; i++) {
56+
const want: string | undefined = FROZEN_KEYS[i];
57+
const got: string | undefined = actual[i];
58+
if (want !== got) out.push(`[${i}] ${want ?? "<缺>"} → ${got ?? "<缺>"}`);
59+
}
60+
return out;
61+
}
62+
63+
/** ② 的捕获器:`` `layout:<名字>:${key}` `` 形态的模板字面量(读 2 处 + 写 2 处共用同一前缀)。 */
64+
const KEY_TEMPLATE = /`layout:([A-Za-z0-9_-]+):\$\{key\}`/g;
65+
66+
/** ② 的判据本体:源码里用到的持久化键前缀集合(排序后比对;数出现次数会绑死实现形态)。 */
67+
function keyPrefixes(src: string): string[] {
68+
return [...new Set([...src.matchAll(KEY_TEMPLATE)].map((m) => m[1]))].sort();
69+
}
70+
71+
const HOOK_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks", "useColumnLayout.ts");
72+
const HOOK_SRC = readFileSync(HOOK_PATH, "utf8");
73+
74+
describe("列键名冻结(改名 = 用户列宽静默丢失)", () => {
75+
it("① 13 个键逐字等于冻结清单(长度 + 顺序 + 拼写)", () => {
76+
expect(FROZEN_KEYS, "冻结清单自身被改过(13 行 = 规格 §6.2 的 13 行)").toHaveLength(13);
77+
expect(keyDiff(COLUMN_KEYS), "列键名漂移 ⇒ 用户已记住的列宽会静默丢失").toEqual([]);
78+
});
79+
80+
it("① 阴性样本:改名 / 重排 / 读不到注册表 各必红(走同一条判据)", () => {
81+
const renamed = COLUMN_KEYS.map((k) => (k === "notes-list" ? "notes-list-v2" : k));
82+
expect(keyDiff(renamed)).toEqual(["[4] notes-list → notes-list-v2"]);
83+
84+
const swapped = [...COLUMN_KEYS.slice(0, 4), COLUMN_KEYS[5], COLUMN_KEYS[4], ...COLUMN_KEYS.slice(6)];
85+
expect(keyDiff(swapped)).toEqual(["[4] notes-list → notes-outline", "[5] notes-outline → notes-list"]);
86+
87+
// 0 命中(注册表读空/路径写错)时必须全红,不能是空转判据
88+
expect(keyDiff([])).toHaveLength(13);
89+
});
90+
91+
it("② 持久化键前缀仍是 layout:col-width: / layout:col-fold:", () => {
92+
expect(keyPrefixes(HOOK_SRC), "持久化键前缀漂移 ⇒ 13 个键一起失效(用户列宽静默丢失)").toEqual([
93+
"col-fold",
94+
"col-width",
95+
]);
96+
// 阴性样本:前缀改一个字符 ⇒ 同一函数立刻报出漂移
97+
expect(keyPrefixes("window.localStorage.getItem(`layout:col-w:${key}`)")).toEqual(["col-w"]);
98+
// 阳性对照:真实源码确实读到了(防路径写错 ⇒ 空集合假绿)
99+
expect(HOOK_SRC).toContain("useColumnLayout(");
100+
});
101+
});

‎app/src/shell/shellReset.test.ts‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// @vitest-environment node
2+
/**
3+
* @ai-context `app/index.html` 的**文档级 reset 与 6px 滚动条**守卫(T0 报告 §二 · G5 的落点)。
4+
*
5+
* Why 需要它:reset(`html, body, #root { margin: 0; height: 100% }`)与细滚动条住在 Vite 的
6+
* HTML 入口里、**不在任何 TS 模块中** ⇒ 全仓 **0 个测试读它**(T0 实测:只有
7+
* `scripts/check-bundle-budget.mjs` 引用构建产物 `dist/index.html`)⇒ 删掉、改宽都不会红。
8+
* 批 3 已实证这里踩过坑:Chromium ≥121 起 `* { scrollbar-width: thin }` **压过**
9+
* `::-webkit-scrollbar`(实测 10px vs 6px)—— 二者互斥,加回前者等于**静默**把滚动条变宽。
10+
*
11+
* 口径(三条,各自独立可红):
12+
* ① `html, body, #root` 规则在位,且 `margin: 0` 与 `height: 100%` **两条声明都在**;
13+
* ② `::-webkit-scrollbar` 规则在位,且 `width` / `height` **恰为 6px**(规格 §1 决策 17);
14+
* ③ 样式里**不得**出现 `scrollbar-width:` / `scrollbar-color:`(它们会让 ② 静默失效)。
15+
*
16+
* ★ 判定前**先剥注释**(HTML 注释 + CSS 块注释):index.html 的说明文字里逐字写着
17+
* `scrollbar-width` 与「实测 10px ≠ 6px」—— 不剥注释,③ 会被自己的说明文字误伤
18+
* (本批已多次因注释里的字面量误伤)。末条自检给出「同文本带注释必红 / 剥后必绿」的实证。
19+
*
20+
* 副作用:只读 `app/index.html`。
21+
* 边界(诚实):**本条只证「声明在位」,证不了「渲染出来是 6px」**——jsdom 不算布局,
22+
* 像素证据只能由无头浏览器探针给(批 3 T14 的活)。本文件跑在 vitest 全局 `node` 环境
23+
* (**故意不加 jsdom 头**:只读文件,不需要 DOM)。
24+
*/
25+
import { readFileSync } from "node:fs";
26+
import { dirname, join } from "node:path";
27+
import { fileURLToPath } from "node:url";
28+
import { describe, expect, it } from "vitest";
29+
30+
const APP = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
31+
const HTML_PATH = join(APP, "index.html");
32+
33+
/** 期望值:规格 §1 决策 17 的细滚动条宽度(px)。 */
34+
const SCROLLBAR_PX = 6;
35+
36+
/** 剥 HTML 注释(`<!-- … -->` 抹成等长空白,保住换行 ⇒ 报错仍能定位行号)。 */
37+
function stripHtmlComments(s: string): string {
38+
return s.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
39+
}
40+
41+
/** 剥 CSS 块注释(与 HTML 注释同理:只抹正文,不抹换行)。 */
42+
function stripCssComments(s: string): string {
43+
return s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
44+
}
45+
46+
/** 取文档里**全部** `<style>` 块的正文(reset 与滚动条规则住在内联样式里,不在 TS 模块)。 */
47+
function styleBlocks(html: string): string[] {
48+
return [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
49+
}
50+
51+
/** 判定面 = `app/index.html` 的内联样式(**先剥注释再判**)。 */
52+
function inlineCss(html: string): string {
53+
return styleBlocks(stripHtmlComments(html)).map(stripCssComments).join("\n");
54+
}
55+
56+
/** 取一条规则的声明体:`<选择器> { … }`(三条判据共用这一处解析口径,不各写一份正则)。 */
57+
function ruleBody(css: string, selector: RegExp): string | null {
58+
const m = selector.exec(css);
59+
return m === null ? null : m[1];
60+
}
61+
62+
/** 声明归一化(空白与冒号间距不算差异;口径是「声明在位」,不是排版)。 */
63+
function decls(body: string): string[] {
64+
return body
65+
.split(";")
66+
.map((d) => d.replace(/\s+/g, " ").replace(/\s*:\s*/, ": ").trim())
67+
.filter((d) => d !== "");
68+
}
69+
70+
/** 取声明里的 px 数值(`width: 6px` ⇒ 6;缺失或非 px ⇒ null)。 */
71+
function pxOf(body: string, prop: string): number | null {
72+
const hit = decls(body).find((d) => d.startsWith(`${prop}:`));
73+
if (hit === undefined) return null;
74+
const m = /^(\d+(?:\.\d+)?)px$/.exec(hit.slice(prop.length + 1).trim());
75+
return m === null ? null : Number(m[1]);
76+
}
77+
78+
const RESET = /html\s*,\s*body\s*,\s*#root\s*\{([^}]*)\}/;
79+
const BAR = /::-webkit-scrollbar\s*\{([^}]*)\}/;
80+
const BANNED = /(?:^|[;{\s])(scrollbar-(?:width|color)\s*:)/gm;
81+
82+
/** ① 的判据(返回违规清单;空 = 绿)。 */
83+
function resetViolations(css: string): string[] {
84+
const body = ruleBody(css, RESET);
85+
if (body === null) return ["缺 `html, body, #root { … }` 规则(整窗会留 UA 8px margin 与底缘白条)"];
86+
const d = decls(body);
87+
const bad: string[] = [];
88+
if (!d.some((x) => /^margin: 0(px)?$/.test(x))) bad.push("reset 缺 `margin: 0`");
89+
if (!d.some((x) => /^height: 100%$/.test(x))) bad.push("reset 缺 `height: 100%`");
90+
return bad;
91+
}
92+
93+
/** ② 的判据。 */
94+
function scrollbarViolations(css: string): string[] {
95+
const body = ruleBody(css, BAR);
96+
if (body === null) return ["缺 `::-webkit-scrollbar { … }` 规则(细滚动条退回系统默认宽)"];
97+
const bad: string[] = [];
98+
for (const prop of ["width", "height"]) {
99+
const px = pxOf(body, prop);
100+
if (px !== SCROLLBAR_PX) bad.push(`::-webkit-scrollbar 的 ${prop} 不是 ${SCROLLBAR_PX}px(实测 ${String(px)})`);
101+
}
102+
return bad;
103+
}
104+
105+
/** ③ 的判据。 */
106+
function bannedViolations(css: string): string[] {
107+
return [...css.matchAll(BANNED)].map(
108+
(m) => `出现 ${m[1].trim()} —— Chromium ≥121 起它压过 ::-webkit-scrollbar(实测 10px ≠ 6px)`,
109+
);
110+
}
111+
112+
const HTML = readFileSync(HTML_PATH, "utf8");
113+
const CSS = inlineCss(HTML);
114+
115+
describe("app/index.html:文档级 reset 与 6px 滚动条(规格 §1 决策 17)", () => {
116+
it("① html, body, #root 的 reset 在位(margin: 0 + height: 100%)", () => {
117+
expect(resetViolations(CSS), "index.html 的 reset 被删或被改").toEqual([]);
118+
});
119+
120+
it(`② ::-webkit-scrollbar 的 width / height 恰为 ${SCROLLBAR_PX}px`, () => {
121+
expect(scrollbarViolations(CSS), "细滚动条规则被删或被改宽").toEqual([]);
122+
});
123+
124+
it("③ 不得出现 scrollbar-width / scrollbar-color(它们会压过 ②)", () => {
125+
expect(bannedViolations(CSS), "加回 scrollbar-width / scrollbar-color ⇒ 6px 静默失效").toEqual([]);
126+
});
127+
128+
it("仪器自检:真实文件读得到(防路径写错 ⇒ 空判据假绿)", () => {
129+
expect(HTML).toContain("<!doctype html>");
130+
expect(styleBlocks(HTML).length, "读不到 <style> 块 ⇒ 三条判据都会「合规」得毫无意义").toBeGreaterThan(0);
131+
expect(CSS).toContain("::-webkit-scrollbar");
132+
});
133+
134+
it("仪器自检:三条判据各自的阳性/阴性样本(走同一条代码路径)+ 剥注释实证", () => {
135+
// 两个**合法**片段(阴性样本按需替换其中之一 ⇒ 才能声称「只有那一条红」)
136+
const resetOk = "html, body, #root { margin: 0; height: 100%; }";
137+
const barOk = "::-webkit-scrollbar { width: 6px; height: 6px; }";
138+
139+
// 阳性对照:完整合法片段 ⇒ 三条全绿
140+
expect(resetViolations(`${resetOk}\n${barOk}`)).toEqual([]);
141+
expect(scrollbarViolations(`${resetOk}\n${barOk}`)).toEqual([]);
142+
expect(bannedViolations(`${resetOk}\n${barOk}`)).toEqual([]);
143+
144+
// 阴性对照①:删整条规则 / 只删 `margin: 0` / 只删 `height: 100%` ⇒ **只有** ① 红
145+
expect(resetViolations(barOk)).toHaveLength(1);
146+
expect(resetViolations(`html, body, #root { height: 100%; }\n${barOk}`)).toEqual(["reset 缺 `margin: 0`"]);
147+
expect(resetViolations(`html, body, #root { margin: 0; }\n${barOk}`)).toEqual(["reset 缺 `height: 100%`"]);
148+
expect(scrollbarViolations(`html, body, #root { height: 100%; }\n${barOk}`)).toEqual([]);
149+
150+
// 阴性对照②:只改 width / 只改 height / 删整条规则 ⇒ **只有** ② 红
151+
expect(scrollbarViolations(`${resetOk}\n::-webkit-scrollbar { width: 10px; height: 6px; }`)).toHaveLength(1);
152+
expect(scrollbarViolations(`${resetOk}\n::-webkit-scrollbar { width: 6px; height: 10px; }`)).toHaveLength(1);
153+
expect(scrollbarViolations(resetOk)).toHaveLength(1);
154+
expect(resetViolations(`${resetOk}\n::-webkit-scrollbar { width: 10px; height: 6px; }`)).toEqual([]);
155+
// ② 不吃注释里的数字:说明文字写「实测 10px ≠ 6px」不算违规(走 inlineCss 剥注释)
156+
expect(scrollbarViolations(inlineCss(`<style>/* 实测 10px ≠ 6px */\n${resetOk}\n${barOk}</style>`))).toEqual([]);
157+
158+
// 阴性对照③:加回 scrollbar-width / scrollbar-color ⇒ **只有** ③ 红
159+
const bannedWidth = `${resetOk}\n${barOk}\n* { scrollbar-width: thin; }`;
160+
const bannedColor = `${resetOk}\n${barOk}\nhtml { scrollbar-color: #c9c4b8 transparent; }`;
161+
expect(bannedViolations(bannedWidth)).toHaveLength(1);
162+
expect(bannedViolations(bannedColor)).toHaveLength(1);
163+
expect(resetViolations(bannedWidth)).toEqual([]);
164+
expect(scrollbarViolations(bannedWidth)).toEqual([]);
165+
166+
// ★ 剥注释实证:同一段文本,**不剥**注释时 ③ 把说明文字判成违规、**剥掉**后绿
167+
const commented = `<style>\n<!-- 别加 scrollbar-width: thin(会压过 ::-webkit-scrollbar) -->\n${resetOk}\n${barOk}\n</style>`;
168+
expect(bannedViolations(commented), "不剥注释 ⇒ 被自己的说明文字误伤").toHaveLength(1);
169+
expect(bannedViolations(inlineCss(commented)), "剥注释后必须绿").toEqual([]);
170+
});
171+
});

0 commit comments

Comments
 (0)