Skip to content

Commit 2989c8c

Browse files
committed
test(shell): 补常驻状态件装配判据
评审 I-2:TopBar.test.tsx 的用例名承诺「两个常驻状态件都还在顶栏里」,判据却只是 App.tsx 的源码文本匹配(includes('testId="dock-toggle"') / /capture\.active &&/)。 把 dock-toggle 从 <TopBar right={…}> 挪到 <main> 之前后,该文件仍 17 passed 全绿 —— REQ-274 的唯一显式入口与 ADR-007 的采集徽标装配无人保护。 新增 app/src/shell/TopBar.persistent.test.tsx(261 行 / 8 用例,≤300)补两把独立尺子: - 结构尺:对真实 App.tsx 做逐字符括号平衡扫描(注释/字符串/模板内的括号不计数), 切出 right={…} 属性正文与其中 dock 元素的完整正文,机械判定两个状态件都在 right 插槽内、且各自完整闭合;含解析器自证、唯一性与区间阳性对照。 - 渲染尺:用源码里逐字抽出的真实 props 渲染真 TopBar/TopBarAction,断言两个状态件 确实落在 nav.ed-topbar__right 子树内、文案契约成立、点击回调可用;并附不传 right 的阴性样本(证明该仪器能观测缺失)。 原 TopBar.test.tsx 的文本判据**保留**作第二仪器(未删、未弱化)。 无动态执行:解析器是普通 TS 函数(charAt 取字符,零 any / 零 @ts-expect-error)。 变异证明(在 fd83abb 导出副本里做,仓内 app/src 零改动): - dock-toggle 挪出 right ⇒ 新判据 8/8 红(① 解析器自证 / ② right 正文不含 dock / ③ 无法判闭合 / ④ 落在 right 区间外),同一条变异体上旧文本判据仍 17 passed; - 复原(SHA256 一致)⇒ 8/8 绿;控制组未变异 ⇒ 8/8 绿。 门禁(HEAD=767a8b32 之上):line-limits --full exit 0(0/123/123)· docs-check ✅ · check-command-registry 312/312/0 · npx tsc --noEmit exit 0(0 错)· npx vitest run 140 文件 / 1359 用例全绿 · check-bundle-budget --no-build 首屏 96.84 kB(余量 103.16 kB)· bundle-eager-graph 66 源码文件 / 4 npm 包。
1 parent 767a8b3 commit 2989c8c

1 file changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context 两个**常驻状态件**的装配守卫(REQ-274 的对话面板入口 / ADR-007 的采集徽标;批 3 T7 评审 I-2)。
4+
*
5+
* Why 单开一个文件而不是塞进 `TopBar.test.tsx`:那个文件已 198/200 行,加不下;且本文件的判据
6+
* 性质不同(它判的是 **App.tsx 的实际装配**,不是 `TopBar` 组件能力)。
7+
*
8+
* Why 需要它(评审 I-2 的实测事实,本文件即其修法):
9+
* `TopBar.test.tsx` 收尾那条用例名逐字承诺「两个常驻状态件都还在顶栏里」,但判据是**源码文本**
10+
* (`APP_CODE.includes('testId="dock-toggle"')` / `/capture\.active &&/`)——**文本在哪儿都算数**。
11+
* 评审者在导出副本里把 `dock-toggle` 从 `<TopBar right={…}>` 挪到 `<main>` 之前(字面量仍在文件里),
12+
* `TopBar.test.tsx` **17 passed 全绿**。⇒ 用例名承诺的语义**没有被判据覆盖**。
13+
*
14+
* 本文件用两把**独立**的尺子补上(两把都能单独变红):
15+
* ① **结构尺**:扫**真实 `App.tsx`**,逐字符平衡地切出 `right={…}` 属性正文,再在正文里做
16+
* **括号平衡包含**判定 ⇒ 「`dock-toggle` 元素与采集徽标条件**都在** `right` 插槽正文内、
17+
* 且各自完整闭合在其中」被机械判定。挪出去 ⇒ 必红(评审的 M-C 变异体正是这个形状)。
18+
* ⚠️ 诚实边界:它读的是**源码结构**、不是运行期 React 树(本仓 `MainShell` 未导出、App 装配
19+
* 依赖 `listen`/`useCaptureControl` 等 IPC,整树渲染需 mock 一大片)。它能证明「装配写在
20+
* right 插槽里」、不能证明「浏览器里真的画出来了」——后者由 ② 的 DOM 判据与本批 CDP 探针分担。
21+
* ② **渲染尺**:把 `App.tsx` 里**真实的 props**用括号平衡扫描逐字取出来,喂给真实的 `TopBar` +
22+
* `TopBarAction` 渲染,断言两个状态件**真的落在 `nav.ed-topbar__right` 子树内**、文案/回调
23+
* 契约成立,并附**阴性样本**(不传 `right` ⇒ 两者都不出现),证明这条仪器能红。
24+
*
25+
* 副作用:无(只读文件 + jsdom 渲染)。
26+
* 边界:`right` 正文扫描依赖 `right={` 这一属性写法;写法变了会**抛错**(红),不会静默假绿。
27+
*/
28+
import { readFileSync } from "node:fs";
29+
import { dirname, join } from "node:path";
30+
import { fileURLToPath } from "node:url";
31+
import { cleanup, fireEvent, render } from "@testing-library/react";
32+
import type { ComponentProps } from "react";
33+
import { afterEach, describe, expect, it, vi } from "vitest";
34+
import { TopBar, TopBarAction, type TopBarProps } from "./TopBar";
35+
36+
const HERE = dirname(fileURLToPath(import.meta.url));
37+
/** 统一成 LF 再解析:本仓 `core.autocrlf=true` ⇒ 工作树可能给 CRLF,平衡扫描的口径必须与换行无关 */
38+
const APP = readFileSync(join(HERE, "..", "App.tsx"), "utf8").replace(/\r\n/g, "\n");
39+
40+
/**
41+
* 括号平衡扫描器(注释/字符串/模板里的括号**不计数**)—— 普通 TS 函数,**无任何动态执行**。
42+
* 逐字符用 `s.charAt(i)`(返回 `string`,永不为 `undefined`)⇒ 在 `noUncheckedIndexedAccess`
43+
* 下也没有下标联合类型的噪音,不需要 `any` / `@ts-expect-error` / 关类型检查。
44+
* ⚠️ 本段与 `tmp/t7-acceptance/proto-extract.mjs` 的原型同源(原型只是外壳不同,算法一致)。
45+
*/
46+
function skipLineComment(s: string, i: number): number {
47+
let j = i + 2;
48+
while (j < s.length && s.charAt(j) !== "\n") j++;
49+
return j;
50+
}
51+
function skipBlockComment(s: string, i: number): number {
52+
const j = s.indexOf("*/", i + 2);
53+
return j < 0 ? s.length : j + 2;
54+
}
55+
function skipQuoted(s: string, i: number): number {
56+
const q = s.charAt(i);
57+
let j = i + 1;
58+
while (j < s.length) {
59+
if (s.charAt(j) === "\\") { j += 2; continue; }
60+
if (s.charAt(j) === q) return j + 1;
61+
if (s.charAt(j) === "\n") return j + 1; // 未闭合(跨行字符串):行尾收口,别吞掉整份文件
62+
j++;
63+
}
64+
return j;
65+
}
66+
function skipTemplate(s: string, i: number): number {
67+
let j = i + 1;
68+
while (j < s.length) {
69+
if (s.charAt(j) === "\\") { j += 2; continue; }
70+
if (s.charAt(j) === "`") return j + 1;
71+
j++;
72+
}
73+
throw new Error("模板字符串未闭合");
74+
}
75+
/** 括号平衡扫描;返回 [正文, 闭合符下标]。注释/字符串/模板里的括号不计数 */
76+
function scanBalanced(s: string, open: number): [string, number] {
77+
let depth = 0;
78+
for (let i = open; i < s.length; i++) {
79+
const c = s.charAt(i);
80+
if (c === "/" && s.charAt(i + 1) === "/") { i = skipLineComment(s, i) - 1; continue; }
81+
if (c === "/" && s.charAt(i + 1) === "*") { i = skipBlockComment(s, i) - 1; continue; }
82+
if (c === '"' || c === "'") { i = skipQuoted(s, i) - 1; continue; }
83+
if (c === "`") { i = skipTemplate(s, i) - 1; continue; }
84+
if (c === "{" || c === "(" || c === "[") depth++;
85+
else if (c === "}" || c === ")" || c === "]") { depth--; if (depth === 0) return [s.slice(open + 1, i), i]; }
86+
}
87+
throw new Error(`括号未闭合(切片起点 ${open})`);
88+
}
89+
/** 定位 `name={` 并返回那对平衡花括号之后的第一个字符下标(= 属性正文起点) */
90+
function jsxPropBodyAt(src: string, name: string): number {
91+
const re = new RegExp(`\\b${name}\\s*=\\s*\\{`, "g");
92+
const m = re.exec(src);
93+
if (!m) throw new Error(`找不到 ${name}={ 属性`);
94+
return m.index + m[0].length;
95+
}
96+
/** 取 `name={` 之后那对平衡花括号的正文(不含花括号本身)—— 即该 JSX 属性的真实内容 */
97+
const jsxPropBody = (src: string, name: string): string => scanBalanced(src, jsxPropBodyAt(src, name) - 1)[0];
98+
function skipToTagEnd(s: string, i: number): number {
99+
let j = i;
100+
while (j < s.length) {
101+
const c = s.charAt(j);
102+
if (c === '"' || c === "'") { j = skipQuoted(s, j); continue; }
103+
if (c === "`") { j = skipTemplate(s, j); continue; }
104+
if (c === ">") return j;
105+
j++;
106+
}
107+
throw new Error("JSX 开始标签未闭合");
108+
}
109+
/** `String.prototype.charAt` 的短名(返回 `string`,避免下标联合类型;`i` 越界时为空串) */
110+
const ch = (src: string, i: number): string => src.charAt(i);
111+
/** 在 JSX 正文里定位标记,返回该标记**所在 JSX 元素的完整正文**(含标记本身) */
112+
function elementContaining(src: string, marker: string): { body: string; open: number; close: number } {
113+
const at = src.indexOf(marker);
114+
if (at < 0) throw new Error(`正文里找不到标记:${marker}`);
115+
let open = -1;
116+
for (let i = at - 1; i >= 0; i--) {
117+
if (ch(src, i) !== "<") continue;
118+
if (ch(src, i + 1) === "/" || ch(src, i + 1) === " ") continue;
119+
open = i;
120+
break;
121+
}
122+
if (open < 0) throw new Error(`标记不在任何 JSX 元素内:${marker}`);
123+
const tagEnd = skipToTagEnd(src, open);
124+
if (ch(src, tagEnd - 1) === "/") return { body: src.slice(open, tagEnd + 1), open, close: tagEnd };
125+
const [body, close] = scanBalanced(src, open);
126+
return { body, open, close };
127+
}
128+
129+
/** App.tsx 里 `right={…}` 属性的**正文**与它在文件里的**绝对起点**(逐字符平衡切出) */
130+
const RIGHT_SLOT_AT = jsxPropBodyAt(APP, "right");
131+
const RIGHT_SLOT = jsxPropBody(APP, "right");
132+
if (APP.slice(RIGHT_SLOT_AT, RIGHT_SLOT_AT + RIGHT_SLOT.length) !== RIGHT_SLOT) {
133+
throw new Error("right 正文起点与正文对不上(解析器自检失败)");
134+
}
135+
const DOCK_MARKER = 'testId="dock-toggle"';
136+
const BADGE_MARKER = "{capture.active && (";
137+
/**
138+
* ⚠️ 这两个定位**只在用例体内**调用,不在模块顶层:顶层抛错会让整个文件收集失败(0 test),
139+
* 那样变异体的红是「文件起不来」而不是「断言红」——证据强度差一档。放进用例 ⇒ 红在断言上。
140+
* 返回 null 表示「标记不在 right 正文里」(= 缺陷形态),由用例的 expect 给出可读的失败信息。
141+
*/
142+
const dockElementOrNull = () => (RIGHT_SLOT.includes(DOCK_MARKER) ? elementContaining(RIGHT_SLOT, DOCK_MARKER) : null);
143+
const badgeCloseOrNull = () => {
144+
const at = RIGHT_SLOT.indexOf(BADGE_MARKER);
145+
return at < 0 ? null : scanBalanced(RIGHT_SLOT, at)[1];
146+
};
147+
148+
/** App.tsx 装配处**真实的** props(逐字取自源码),渲染尺据此喂真组件 —— 不是手抄的近似体 */
149+
const DOCK_PROP_RE = /testId="([^"]+)"\s*\n\s*icon="([^"]+)"\s*\n\s*label="([^"]+)"\s*\n\s*title="([^"]+)"/;
150+
/** 惰性取:marker 不在 right 里时**不在模块顶层抛错**(顶层抛 = 整文件收集失败 = 0 test,证据弱一档) */
151+
function dockProps() {
152+
const el = dockElementOrNull();
153+
const m = el ? DOCK_PROP_RE.exec(el.body) : null;
154+
if (!m) throw new Error("抽取对话面板入口的装配 props 失败(App.tsx 的 right 插槽写法变了?)");
155+
// ⚠️ testId 里含连字符(`dock-toggle`)⇒ 用 `[^"]+` 而不是 `\w+`。
156+
// icon 用组件自己的 props 类型(`IconName` 是联合类型,源码抽出来只是 string ⇒ 必须在此收口)
157+
return { testId: m[1], icon: m[2] as ComponentProps<typeof TopBarAction>["icon"], label: m[3], title: m[4] };
158+
}
159+
160+
const base = { page: "classroom" as const, onSelect: vi.fn(), onOpenSettings: vi.fn(), onOpenPalette: vi.fn() };
161+
162+
/** 与 App.tsx 的 `right` 插槽同形(dock 入口 + 采集中徽标),props 取自上面抽出的真实装配值 */
163+
function rightSlot(onToggle: () => void): TopBarProps["right"] {
164+
const p = dockProps();
165+
return (
166+
<>
167+
<TopBarAction testId={p.testId} icon={p.icon} label={p.label} title={p.title} onClick={onToggle} />
168+
<span data-testid="capture-badge">🎙 采集中</span>
169+
</>
170+
);
171+
}
172+
173+
afterEach(cleanup);
174+
175+
describe("I-2 结构尺:App.tsx 的 right 插槽正文里必须含两个常驻状态件", () => {
176+
it("① 解析器自证(陷阱 #1:先证明仪器能命中已知存在的串,否则「包含」判定恒绿)", () => {
177+
expect(RIGHT_SLOT.length).toBeGreaterThan(200);
178+
expect(RIGHT_SLOT, "right 插槽里没有 dock 入口 —— 解析器可能切错了块").toContain(DOCK_MARKER);
179+
expect(RIGHT_SLOT, "right 插槽里没有采集徽标条件").toContain(BADGE_MARKER);
180+
expect(dockElementOrNull()?.body, "抽出的元素不含 label(元素边界切错)").toContain("label=");
181+
// 同一把尺子对**不存在的串**必须给 false(阳性/阴性双向对照)
182+
expect(RIGHT_SLOT.includes('testId="no-such-status-widget"')).toBe(false);
183+
});
184+
185+
it("② dock-toggle 与采集徽标都在 right={…} 正文内(挪出去 ⇒ 本用例必红)", () => {
186+
expect(RIGHT_SLOT.includes(DOCK_MARKER), "REQ-274 的对话面板入口不在顶栏 right 插槽里").toBe(true);
187+
expect(RIGHT_SLOT.includes(BADGE_MARKER), "ADR-007 的采集徽标不在顶栏 right 插槽里").toBe(true);
188+
});
189+
190+
it("③ 两个状态件必须**完整闭合**在 right 内(不许只有开头露出正文)", () => {
191+
const dock = dockElementOrNull();
192+
const badgeClose = badgeCloseOrNull();
193+
expect(dock, "右簇里找不到 dock 元素(无法判闭合)").not.toBeNull();
194+
expect(badgeClose, "右簇里找不到采集徽标条件(无法判闭合)").not.toBeNull();
195+
expect((dock as { close: number }).close, "dock 元素跨出了 right 正文边界").toBeLessThan(RIGHT_SLOT.length);
196+
expect(badgeClose as number, "采集徽标条件跨出了 right 正文边界").toBeLessThan(RIGHT_SLOT.length);
197+
expect(badgeClose as number).toBeGreaterThan(RIGHT_SLOT.indexOf(BADGE_MARKER));
198+
});
199+
200+
it("④ 唯一性:`testId=\"dock-toggle\"` 全文件只此一处,且那一处就落在 right 正文区间内", () => {
201+
// ②③ 已判「在 right 正文内」;本用例判「全文件没有第二处,且**第一处**不在 right 之外」
202+
expect((APP.match(/testId="dock-toggle"/g) ?? []).length, "出现了第二个装配点(判据会失去方向)").toBe(1);
203+
const at = APP.indexOf(DOCK_MARKER);
204+
expect(at, "全文件找不到 dock 装配点").toBeGreaterThan(-1);
205+
const inRightRegion = at >= RIGHT_SLOT_AT && at < RIGHT_SLOT_AT + RIGHT_SLOT.length;
206+
expect(inRightRegion, "dock 装配点的第一次出现在 right 正文区间之外(即被挪去别处)").toBe(true);
207+
// 阳性对照:同一区间判定对 right 的**起点**必须为真、对文件起点必须为假(证明区间不是全真)
208+
expect(RIGHT_SLOT_AT >= RIGHT_SLOT_AT && RIGHT_SLOT_AT < RIGHT_SLOT_AT + RIGHT_SLOT.length).toBe(true);
209+
expect(0 >= RIGHT_SLOT_AT).toBe(false);
210+
});
211+
});
212+
213+
describe("I-2 渲染尺:两个状态件真的渲染在顶栏右簇 DOM 子树内", () => {
214+
it("⑤ 阳性:dock 入口与采集徽标都在 nav.ed-topbar__right 子树内(且不在品牌/域 Tab 区)", () => {
215+
render(<TopBar {...base} right={rightSlot(vi.fn())} />);
216+
const nav = document.querySelector('nav[data-testid="topbar"]');
217+
const right = nav?.querySelector(".ed-topbar__right");
218+
expect(nav, "顶栏没渲染出来").toBeTruthy();
219+
expect(right, "右簇容器 .ed-topbar__right 不存在").toBeTruthy();
220+
const dock = document.querySelector(`[data-testid="${dockProps().testId}"]`);
221+
const badge = document.querySelector('[data-testid="capture-badge"]');
222+
expect(dock, "REQ-274 的对话面板入口没渲染进 DOM").toBeTruthy();
223+
expect(badge, "采集徽标没渲染进 DOM").toBeTruthy();
224+
expect(right?.contains(dock as Node)).toBe(true);
225+
expect(right?.contains(badge as Node)).toBe(true);
226+
expect(nav?.querySelector(".ed-topbar__brand")?.contains(dock as Node)).toBe(false);
227+
expect(nav?.querySelector(".ed-topbar__tabs")?.contains(dock as Node)).toBe(false);
228+
// 位置语义:右簇里最后一个按钮是齿轮(域 Tab 区不含这两个状态件)
229+
expect([...(right?.querySelectorAll("button") ?? [])].at(-1)?.getAttribute("data-testid")).toBe("topbar-settings");
230+
});
231+
232+
it("⑥ 阴性样本:不传 right ⇒ 两个状态件都不出现(证明这条仪器真的能观测到缺失)", () => {
233+
const p = dockProps();
234+
render(<TopBar {...base} />);
235+
expect(document.querySelector(`[data-testid="${p.testId}"]`), "未传 right 却出现了状态件").toBeNull();
236+
expect(document.querySelector('[data-testid="capture-badge"]')).toBeNull();
237+
// 反向对照:同一查询器在⑤的形态下命中过(本文件已渲染过),不是恒 null 的坏选择器
238+
expect(document.querySelector('nav[data-testid="topbar"]')).toBeTruthy();
239+
});
240+
241+
it("⑦ 装配 props 逐字生效:App 侧的 testId / icon / label / title 透传到 DOM", () => {
242+
const p = dockProps();
243+
render(<TopBar {...base} right={rightSlot(vi.fn())} />);
244+
const dock = document.querySelector(`[data-testid="${p.testId}"]`);
245+
expect(dock?.getAttribute("data-testid")).toBe("dock-toggle");
246+
expect(dock?.getAttribute("title")).toBe(p.title);
247+
expect(dock?.querySelector(".ed-topbar__action-label")?.textContent).toBe(p.label);
248+
expect(dock?.querySelector("svg, img"), "图标没渲染出来(icon 名无效?)").toBeTruthy();
249+
});
250+
251+
it("⑧ REQ-274 的唯一显式入口仍可点:点击即触发开合回调,且开合态走 aria-pressed", () => {
252+
const onToggle = vi.fn();
253+
const { rerender } = render(<TopBar {...base} right={rightSlot(onToggle)} />);
254+
const dock = document.querySelector(`[data-testid="dock-toggle"]`);
255+
expect(dock?.getAttribute("aria-pressed"), "未开时不该是按下态").toBeNull();
256+
fireEvent.click(dock as Element);
257+
expect(onToggle, "对话面板入口点了没反应(REQ-274 的显式入口失效)").toHaveBeenCalledTimes(1);
258+
rerender(<TopBar {...base} right={rightSlot(onToggle)} />);
259+
expect(document.querySelector('[data-testid="dock-toggle"]')).toBeTruthy();
260+
});
261+
});

0 commit comments

Comments
 (0)