Skip to content

Commit 4f90d89

Browse files
committed
test(ui): z-index 棘轮守卫(裸数字只许减少)
1 parent 3e2c855 commit 4f90d89

1 file changed

Lines changed: 184 additions & 0 deletions

File tree

‎app/src/ui/zIndex.guard.test.ts‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { readdirSync, readFileSync, statSync } from "node:fs";
2+
import { dirname, join, relative, sep } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { describe, expect, it } from "vitest";
5+
6+
/**
7+
* @ai-context **棘轮守卫**:裸数字 z-index 只许减少(ADR-032 决策 5 的执行手段)。
8+
*
9+
* Why:批 0-A 已交付六档标尺 `ui/zIndex.ts`(消费方式 `zIndex("modal")`),但全仓仍有
10+
* **43 文件 / 58 行**裸数字(recon §10),17 个不同值分裂成 10…61 与 900…1150 两个
11+
* **不相交**的段 —— 叠放顺序是涌现的而不是被设计的。批 4 才整段迁移,在那之前必须有
12+
* 机器判据拦住「新代码继续写裸数字」,否则标尺会退化成「又一套并存的东西」
13+
* (先例:`ui/icons/no-inline-svg.test.ts`)。本守卫**不要求本批清理存量**,只要求不倒退。
14+
*
15+
* 副作用:只读磁盘(递归遍历 `app/src`)。不修改任何文件。
16+
* 边界:批 4 每迁完一处,应从名单里删掉对应条目(第 2 个 `it` 会要求删)。
17+
*
18+
* ★ 检索口径(四项逐条声明 —— 口径本身是守卫的一部分,改口径等于改守卫)
19+
* ① 范围:`app/src` 递归;文件 = `*.ts` / `*.tsx`(内联 `zIndex: <num>`)与
20+
* `*.css`(`z-index: <num>`)。**不含** `.mjs`(token 生成器在 `app/scripts/`,
21+
* 不在 `app/src` 下)、不含 `app/src-tauri`(无 TS)。
22+
* ② 排除测试:路径以 `.test.ts` / `.test.tsx` 结尾者**整体跳过**。理由是实测的 3 行
23+
* `components/CanvasNodes.test.tsx:38,41,44` 的 `zIndex: 0` 是 React Flow 的
24+
* **节点字段**而**不是样式**(把它算进来会让名单混入非样式命中,棘轮语义被稀释)。
25+
* 已知缺口:测试文件里新增的裸数字**样式**同样不会被拦下(测试不承载观感契约)。
26+
* ③ `ui/zIndex.ts` 自身**不豁免**:它用 `Z_TIER` 常量表定义档位,不出现 `zIndex: <num>`
27+
* 形态,故天然 0 命中;第 4 个 `it` 把「它在扫描域内且自身 0 命中」钉成断言 ——
28+
* 若有人在标尺模块里写死裸数字,本守卫照样红。
29+
* ④ 匹配形态:`/zIndex\s*:\s*-?\d+/`(内联)与 `/z-index\s*:\s*-?\d+/`(CSS)。
30+
* 合法写法 `zIndex: zIndex("modal")` / `z-index: var(--ed-…)` **不匹配**、不被误伤;
31+
* 字符串形态 `zIndex: "300"` 与 `zIndex:` 换行到下一行的写法**不匹配**(已知缺口,已登记)。
32+
* 命中键 = `相对 app/src 的正斜杠路径::该行 trim 后的原文` —— **刻意不含行号**:行号会随
33+
* 上方插行/删行漂移,把名单变成天天假红的噪声源;报错信息里则带 **路径:行号** 便于定位。
34+
*/
35+
36+
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..");
37+
38+
/** 内联形态(TS/TSX):`zIndex: 300` · `zIndex:300` · `zIndex: -1` */
39+
const INLINE_PATTERN = /zIndex\s*:\s*-?\d+/;
40+
/** CSS 形态:`z-index: 300`(`z-index: var(--ed-…)` 不匹配) */
41+
const CSS_PATTERN = /z-index\s*:\s*-?\d+/;
42+
43+
/** 一条命中:`key` 进冻结名单(无行号,抗行漂移);`location` 只用于报错定位 */
44+
interface RawZIndexHit {
45+
key: string;
46+
location: string;
47+
text: string;
48+
}
49+
50+
/**
51+
* 改造前就存在的裸数字 z-index(相对 `app/src`,正斜杠)—— **只允许减少**。
52+
*
53+
* 由 Task 2 守卫首次运行的实测输出逐行填入(58 条 = recon §10 的 58 lines / 43 files),
54+
* 与 `FROZEN_OVER_LIMIT` 的棘轮同源:名单只能缩短,不能加长。
55+
*/
56+
const FROZEN_NUMERIC_ZINDEX: readonly string[] = [
57+
"components/AiConversationDock.tsx::position: \"fixed\", top: 56, right: 0, bottom: 0, width: PANEL_W, zIndex: 900,",
58+
"components/BoxSelectOverlay.tsx::zIndex: 10,",
59+
"components/BoxSelectOverlay.tsx::zIndex: 20,",
60+
"components/BrowserChrome.tsx::zIndex: 1000,",
61+
"components/CaptureOverlayPanel.tsx::<div style={{ position: \"fixed\", bottom: 24, left: \"50%\", transform: \"translateX(-50%)\", display: \"flex\", gap: 8, zIndex: 10 }}>",
62+
"components/ChatSaveNoteDialog.tsx::position: \"fixed\", zIndex: 51, top: \"50%\", left: \"50%\",",
63+
"components/ChatSaveNoteDialog.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 50, background: \"rgba(0,0,0,0.18)\" }}",
64+
"components/GoalPlanApprovalDialog.tsx::<div style={{ position: \"fixed\", inset: 0, background: \"rgba(0,0,0,.35)\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 60 }}>",
65+
"components/GraduateDialog.tsx::<div style={{ position: \"fixed\", inset: 0, background: \"rgba(0,0,0,.35)\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 60 }}>",
66+
"components/GroupCreateDialog.tsx::style={{ position: \"fixed\", inset: 0, background: \"rgba(0,0,0,0.45)\", zIndex: 1000, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}",
67+
"components/GroupDeleteConfirm.tsx::style={{ position: \"fixed\", inset: 0, background: \"rgba(0,0,0,0.45)\", zIndex: 1000, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}",
68+
"components/GroupRowContextMenu.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 60, background: \"transparent\" }}",
69+
"components/GroupRowContextMenu.tsx::zIndex: 61,",
70+
"components/GroupSidebarRow.tsx::style={{ position: \"absolute\", top: \"100%\", left: 8, zIndex: 20, background: \"#fff\", border: \"1px solid #e5e7eb\", borderRadius: 6, padding: 8, boxShadow: \"0 4px 12px rgba(0,0,0,0.12)\" }}",
71+
"components/ImagePreviewOverlay.tsx::zIndex: 1000,",
72+
"components/InterviewDialog.tsx::<div style={{ position: \"fixed\", inset: 0, background: \"rgba(0,0,0,.35)\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 60 }}>",
73+
"components/KnowledgeConceptDialog.tsx::display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 50,",
74+
"components/KnowledgeDecisionForm.tsx::<div style={{ position: \"fixed\", inset: 0, background: \"rgba(0,0,0,0.45)\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 50 }} onClick={onClose}>",
75+
"components/KnowledgeModelDialog.tsx::display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 50,",
76+
"components/KnowledgeSystemWizard.tsx::display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 50,",
77+
"components/ModelCardCreateDialog.tsx::style={{ position: \"fixed\", inset: 0, background: \"rgba(0,0,0,0.45)\", zIndex: 1000, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}",
78+
"components/ModelCardFromNoteDialog.tsx::zIndex: 1000,",
79+
"components/NoteAiDialog.tsx::style={{ position: \"fixed\", inset: 0, background: \"rgba(17,24,39,.45)\", zIndex: 1000,",
80+
"components/NoteEditView.tsx::<div onClick={() => setHighlightOpen(false)} style={{ position: \"fixed\", inset: 0, zIndex: 30, background: \"transparent\" }} />",
81+
"components/NoteEditView.tsx::style={{ position: \"absolute\", top: \"100%\", left: 0, zIndex: 31, background: \"#fff\", border: \"1px solid #e5e7eb\", borderRadius: 6, padding: 8, boxShadow: \"0 4px 12px rgba(0,0,0,0.12)\", maxWidth: 200 }}",
82+
"components/NoteHeaderActions.tsx::style={{ position: \"absolute\", top: \"100%\", right: 0, zIndex: 30, background: \"#fff\", border: \"1px solid #e5e7eb\", borderRadius: 6, padding: 8, boxShadow: \"0 4px 12px rgba(0,0,0,0.12)\" }}",
83+
"components/NoteLinkToSystem.tsx::position: \"absolute\", zIndex: 31, top: \"calc(100% + 6px)\", right: 0,",
84+
"components/NoteLinkToSystem.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 30, background: \"transparent\" }}",
85+
"components/NoteListBatchMenu.tsx::<div data-testid=\"batch-context-menu\" style={{ position: \"fixed\", zIndex: 41, left: x || 12, top: y || 12, minWidth: 180, background: \"#fff\", border: \"1px solid #e5e7eb\", borderRadius: 8, boxShadow: \"0 6px 18px rgba(0,0,0,0.12)\", padding: 6, fontSize: 12 }}>",
86+
"components/NoteListBatchMenu.tsx::<div style={{ position: \"fixed\", inset: 0, zIndex: 40 }} onClick={onClose} />",
87+
"components/NoteMoveToGroupMenu.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 30, background: \"transparent\" }}",
88+
"components/NoteMoveToGroupMenu.tsx::zIndex: 31,",
89+
"components/NoteRowContextMenu.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 60, background: \"transparent\" }}",
90+
"components/NoteRowContextMenu.tsx::zIndex: 61,",
91+
"components/PracticeQuestionsOverlays.tsx::zIndex: 1150,",
92+
"components/ProofreadPanel.tsx::zIndex: 1000,",
93+
"components/RefineLaunchDialog.tsx::style={{ position: \"fixed\", inset: 0, background: \"rgba(17,24,39,.45)\", zIndex: 1000,",
94+
"components/RefineWorkbench.tsx::position: \"fixed\", inset: 0, zIndex: 999,",
95+
"components/RichEditorView.tsx::<div onClick={() => setHighlightOpen(false)} style={{ position: \"fixed\", inset: 0, zIndex: 30, background: \"transparent\" }} />",
96+
"components/RichEditorView.tsx::style={{ position: \"absolute\", top: \"100%\", left: 0, zIndex: 31, background: \"#fff\", border: \"1px solid #e5e7eb\", borderRadius: 6, padding: 8, boxShadow: \"0 4px 12px rgba(0,0,0,0.12)\", maxWidth: 200 }}",
97+
"components/RouteInfoPopover.tsx::position: \"fixed\", zIndex: 31, width: 300, maxHeight: \"70vh\",",
98+
"components/RouteInfoPopover.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 30, background: \"transparent\" }}",
99+
"components/ScreenSelectOverlay.tsx::style={{ position: \"absolute\", inset: 0, background: \"rgba(255,255,255,0.96)\", zIndex: 20, display: \"flex\", flexDirection: \"column\", alignItems: \"center\", justifyContent: \"center\", gap: 8 }}",
100+
"components/ScreenSelectOverlay.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 999, background: \"rgba(17,24,39,0.45)\", cursor: \"crosshair\" }}",
101+
"components/SecondPassPanel.tsx::zIndex: 1000,",
102+
"components/SessionRowContextMenu.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 60, background: \"transparent\" }}",
103+
"components/SessionRowContextMenu.tsx::zIndex: 61,",
104+
"components/SopRunOverlay.tsx::zIndex: 1100,",
105+
"components/SystemStatusBadge.tsx::zIndex: 20,",
106+
"components/TaskLaunchDialog.tsx::<div onClick={onClose} data-testid=\"task-launch-backdrop\" style={{ position: \"fixed\", inset: 0, zIndex: 50, background: \"rgba(0,0,0,0.18)\" }} />",
107+
"components/TaskLaunchDialog.tsx::position: \"fixed\", zIndex: 51, top: \"50%\", left: \"50%\", transform: \"translate(-50%, -50%)\",",
108+
"components/WindowSelectCard.tsx::zIndex: 20,",
109+
"components/note-selection/SelectionActionMenu.tsx::style={{ position: \"fixed\", inset: 0, zIndex: 60, background: \"transparent\" }}",
110+
"components/note-selection/SelectionActionMenu.tsx::zIndex: 61,",
111+
"hooks/useTransientToast.tsx::zIndex: 200,",
112+
"pages/ChatPage.tsx::<div data-testid=\"task-launch-menu\" data-app-menu=\"\" style={{ position: \"absolute\", top: \"100%\", left: 0, zIndex: 31, background: \"#fff\", border: \"1px solid #e5e7eb\", borderRadius: 6, padding: 4, boxShadow: \"0 4px 12px rgba(0,0,0,0.12)\", minWidth: 180 }}>",
113+
"pages/ChatPage.tsx::<div onClick={() => setLaunchMenuOpen(false)} style={{ position: \"fixed\", inset: 0, zIndex: 30, background: \"transparent\" }} />",
114+
"pages/SessionsPage.tsx::zIndex: 100,",
115+
];
116+
117+
/** 递归收集 `app/src` 下受管辖的源文件(口径①) */
118+
function collectFiles(dir: string, out: string[] = []): string[] {
119+
for (const entry of readdirSync(dir)) {
120+
const full = join(dir, entry);
121+
if (statSync(full).isDirectory()) collectFiles(full, out);
122+
else if (/\.tsx?$/.test(entry) || entry.endsWith(".css")) out.push(full);
123+
}
124+
return out;
125+
}
126+
127+
/** 按口径①②④ 全仓扫描,返回按 `key` 排序的命中明细 */
128+
function numericZIndexHits(): RawZIndexHit[] {
129+
const hits: RawZIndexHit[] = [];
130+
for (const file of collectFiles(SRC)) {
131+
const isCss = file.endsWith(".css");
132+
if (!isCss && /\.test\.tsx?$/.test(file)) continue; // 口径②
133+
const rel = relative(SRC, file).split(sep).join("/");
134+
const pattern = isCss ? CSS_PATTERN : INLINE_PATTERN;
135+
readFileSync(file, "utf8")
136+
.split(/\r?\n/)
137+
.forEach((line, index) => {
138+
if (!pattern.test(line)) return;
139+
const text = line.trim();
140+
hits.push({ key: `${rel}::${text}`, location: `${rel}:${index + 1}`, text });
141+
});
142+
}
143+
return hits.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
144+
}
145+
146+
/** 违规明细格式 = `相对路径:行号 该行原文`(报错必须点名到行,否则无法定位) */
147+
function describeHits(hits: readonly RawZIndexHit[]): string {
148+
return hits.map((h) => `${h.location} ${h.text}`).join("\n");
149+
}
150+
151+
describe("z-index 棘轮", () => {
152+
it("不得新增裸数字 z-index(只允许减少)", () => {
153+
const current = numericZIndexHits();
154+
const added = current.filter((h) => !FROZEN_NUMERIC_ZINDEX.includes(h.key));
155+
expect(
156+
added.map((h) => `${h.location} ${h.text}`),
157+
`新增了裸数字 z-index(请改用 zIndex("<档名>"),六档见 ui/zIndex.ts):\n${describeHits(added)}`,
158+
).toEqual([]);
159+
// 名单无重复(第 3 个 it)⇒「命中行数 ≤ 名单长度」就等价于棘轮单调。这一条闭合上面那条
160+
// 集合差集的漏洞:在同一文件里**整行复制**一条文本完全相同的 `zIndex: 1000,`,key 会撞车。
161+
expect(current.length, "裸数字 z-index 的行数变多了(只允许减少)").toBeLessThanOrEqual(
162+
FROZEN_NUMERIC_ZINDEX.length,
163+
);
164+
});
165+
166+
it("冻结名单没有过期项(批 4 迁完要及时从名单删除)", () => {
167+
const current = new Set(numericZIndexHits().map((h) => h.key));
168+
const stale = FROZEN_NUMERIC_ZINDEX.filter((key) => !current.has(key));
169+
expect(stale, `名单里这些行已不含裸数字 z-index,请删除:\n${stale.join("\n")}`).toEqual([]);
170+
});
171+
172+
it("名单非空且已排序、无重复(防空名单/失序把棘轮静默关掉)", () => {
173+
expect(FROZEN_NUMERIC_ZINDEX.length).toBeGreaterThan(0);
174+
expect([...FROZEN_NUMERIC_ZINDEX]).toEqual([...FROZEN_NUMERIC_ZINDEX].sort());
175+
expect(new Set(FROZEN_NUMERIC_ZINDEX).size).toBe(FROZEN_NUMERIC_ZINDEX.length);
176+
});
177+
178+
it("扫描域锚点:标尺模块在扫描域内且自身 0 命中(口径③:不设豁免)", () => {
179+
const inDomain = collectFiles(SRC).map((f) => relative(SRC, f).split(sep).join("/"));
180+
expect(inDomain.includes("ui/zIndex.ts"), "标尺模块掉出了扫描域(口径①失效)").toBe(true);
181+
const fromScale = numericZIndexHits().filter((h) => h.location.startsWith("ui/zIndex.ts:"));
182+
expect(fromScale.map((h) => h.location), "标尺模块里出现了裸数字 z-index").toEqual([]);
183+
});
184+
});

0 commit comments

Comments
 (0)