Skip to content

Commit d9d0dfc

Browse files
committed
fix(shell): 加固 T10/T13 五条评审 Minor 判据
1 parent 2b42623 commit d9d0dfc

3 files changed

Lines changed: 79 additions & 15 deletions

File tree

‎app/src/components/ClassroomRightPane.test.tsx‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const HERE = dirname(fileURLToPath(import.meta.url));
2727
const SRC = readFileSync(join(HERE, "ClassroomRightPane.tsx"), "utf8");
2828
/** 只留代码:剥块注释(含 JSX 注释)与整行 `//` 注释 —— 注释里提到宽度上限不算犯规、也不算数 */
2929
const CODE = SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
30+
/** 唯一有效口径 = **属性用法**(见 ③ 用例的注释:裸词口径在行尾注释/字符串字面量下会假红) */
31+
const CAPS = /maxWidth\s*:/g;
3032

3133
const note: Note = { id: 1, title: "课堂笔记", content: "正文", source: "manual", tags: "[]", pin: 0, group_id: null, created_at: 0, updated_at: 0 };
3234
const base = {
@@ -74,7 +76,11 @@ describe("课堂右栏统一 wrapper(规格 §6.2)", () => {
7476

7577
it("② 渲染出的上限 === 注册表 settings-main 的默认宽(数字只有一份)", () => {
7678
const { container } = render(<ClassroomRightPane {...base} />);
77-
expect(capped(container)[0].style.maxWidth).toBe(`${columnSpec("settings-main").default}px`);
79+
const wrapper = capped(container);
80+
expect(wrapper[0].style.maxWidth).toBe(`${columnSpec("settings-main").default}px`);
81+
// 规格 §6.2「居中 860」的另一半:上限之外还要**水平居中**。jsdom 会把 `margin: "0 auto"`
82+
// 归一化成 `"0px auto"`(实测)—— 这半句此前无任何判据(T10 评审 Minor-2:去掉居中 0 红)。
83+
expect(wrapper[0].style.margin, "wrapper 没居中(只钉了宽、丢了「居中」那半句)").toBe("0px auto");
7884
});
7985

8086
it("① 融合卡的行为不变(wrapper 只改宽,不改事件语义)", () => {
@@ -89,8 +95,13 @@ describe("课堂右栏统一 wrapper(规格 §6.2)", () => {
8995
expect(onDismissFused).toHaveBeenCalledTimes(1);
9096
});
9197

92-
it("③ 剥注释后:宽度上限只出现一次(V2 口径)", () => {
93-
expect([...CODE.matchAll(/maxWidth/g)]).toHaveLength(1);
98+
it("③ 剥注释后:宽度上限(属性用法)只出现一次(V2 口径)", () => {
99+
// 口径 = **属性用法**(`maxWidth` 后跟冒号),不是裸词:剥注释器只剥块注释与**整行** `//`,
100+
// 因此**行尾** `//` 注释或字符串字面量里的裸词会假红(T10 评审 M3d/M3e);收紧成属性用法后
101+
// 两者转绿,而**真第二处上限**(`maxWidth: 700`)仍被计数 ⇒ 牙齿不降(见 ③ 仪器自检的样本表)。
102+
expect([...CODE.matchAll(CAPS)], "宽度上限(属性用法)只该出现一次").toHaveLength(1);
103+
// 第二仪器:原裸词口径保留(互补 —— 它不放行裸词/字符串里的同名词;两者同时绿才算干净)
104+
expect([...CODE.matchAll(/maxWidth/g)], "剥注释后仍出现裸 maxWidth(第二仪器,裸词口径)").toHaveLength(1);
94105
// 仪器自检:剥注释确实起了作用,且剥完不是空文件(否则 ③ 是空判据)
95106
expect(SRC.length).toBeGreaterThan(CODE.length);
96107
expect(CODE).toContain("export default function ClassroomRightPane");
@@ -107,11 +118,20 @@ describe("课堂右栏统一 wrapper(规格 §6.2)", () => {
107118
expect(/(?<![A-Za-z0-9_])860(?![0-9])/.test(CODE), "860 又被抄了一份(应从注册表取)").toBe(false);
108119
});
109120

110-
it("③ 仪器自检:上面的计数与裸数字正则会命中已知样本(否则那几条是空判据)", () => {
111-
const probe = "const a = { maxWidth: 640, width: 860 };";
112-
expect([...probe.matchAll(/maxWidth/g)]).toHaveLength(1);
113-
expect(/(?<![A-Za-z0-9_])640(?![0-9])/.test(probe)).toBe(true);
114-
expect(/(?<![A-Za-z0-9_])860(?![0-9])/.test(probe)).toBe(true);
121+
it("③ 仪器自检:两条计数口径都命中真上限,且裸词口径的两种假红形态被新口径放行", () => {
122+
const real = "const a = { maxWidth: 640, width: 860 };";
123+
expect([...real.matchAll(CAPS)], "新口径漏掉真上限 ⇒ 判据没牙").toHaveLength(1);
124+
expect([...real.matchAll(/maxWidth/g)], "裸词口径应有 1 处").toHaveLength(1);
125+
// 假红形态(剥注释器盲区):行尾 // 注释 / 字符串字面量 —— 新口径放行、裸词口径误伤
126+
const trailing = "const a = 1; // maxWidth 探针";
127+
expect([...trailing.matchAll(CAPS)], "新口径误伤行尾注释").toHaveLength(0);
128+
expect([...trailing.matchAll(/maxWidth/g)], "裸词口径本来就会误伤行尾注释").toHaveLength(1);
129+
const inString = 'const s = "maxWidth";';
130+
expect([...inString.matchAll(CAPS)], "新口径误伤字符串字面量").toHaveLength(0);
131+
expect([...inString.matchAll(/maxWidth/g)], "裸词口径本来就会误伤字符串字面量").toHaveLength(1);
132+
// 裸数字口径的阳性对照(否则「无裸 640/860」那条是空判据)
133+
expect(/(?<![A-Za-z0-9_])640(?![0-9])/.test(real)).toBe(true);
134+
expect(/(?<![A-Za-z0-9_])860(?![0-9])/.test(real)).toBe(true);
115135
// 阴性对照:「640」是更长数字的前缀时不算命中(防子串误判)
116136
expect(/(?<![A-Za-z0-9_])640(?![0-9])/.test("width: 6401")).toBe(false);
117137
});

‎app/src/shell/ShellFallback.test.tsx‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77
* 保活(先点两次计数器再让子页抛错,随后断言计数仍是 2 —— 这是证明,不是声明)。
88
* ② **结构尺**(真 `App.tsx` 源码):**按槽位逐个判** —— 9 个 `PageSlot` 调用点与注册表 9 个 key
99
* 一一对应,且每个调用点都经过那唯一一个内部包了边界的 `PageSlot` 定义;三个非槽位 Suspense
10-
* (overlay / float / dock)各有边界。⚠️ Why 不数全文出现次数:import 行 / 注释 / 类型标注都会计入
11-
* (计划 V3 的 `boundaries≥4` 因此偏松),且那条正则**对空格敏感**(`fallback={ null }` 漏判)。
10+
* (overlay / float / dock)的边界必须**真的包住自己那个面板**(用括号平衡取内层,而非「本段
11+
* 出现过这两个 token」—— 后者在边界与面板解耦时静默通过)。⚠️ Why 不数全文出现次数:import 行 /
12+
* 注释 / 类型标注都会计入(计划 V3 的 `boundaries≥4` 因此偏松),且那条正则**对空格敏感**
13+
* (`fallback={ null }` 漏判)。
1214
*
1315
* ⚠️ 诚实边界:本仓**没有** `App.test.tsx`(批 2 实测)⇒ `App.tsx` 的接线**无渲染级测试面**;① 测的是
1416
* 与 App **同构**的复刻(② 证明两者同形),真产物像素证据归 T14。
17+
* ⚠️ 失败卡文案(`:77`)是**四处共用**的一句话(PageSlot / overlay / float / dock)⇒ 判据把它整句冻结,
18+
* 并显式禁掉「可切换页面」这类只在主窗成立的说法(T13 评审 Minor-3)。
1519
*/
1620
import { readFileSync } from "node:fs";
1721
import { dirname, join } from "node:path";
@@ -27,6 +31,23 @@ const APP = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "Ap
2731
.replace(/\r\n/g, "\n").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
2832
/** 模拟懒 chunk 加载失败(渲染期抛错 ⇒ React 落到最近的上层边界);抛错时 React 仍会 console.error ⇒ 抑制后还原 */
2933
function Boom(): never { throw new Error("模拟懒 chunk 加载失败"); }
34+
/**
35+
* 取出 `src.slice(i)` 里**第一个整段** `<tag>…</tag>`(开闭标签按同名嵌套做奇偶配对 —— 不用 eval/
36+
* new Function),返回其**内层文本**;起点不是该开标签或找不到配对 ⇒ null(判据据此响亮失败)。
37+
* Why 要它:只 `toContain("<SlotErrorBoundary>")` 判不出「边界**包住**面板」还是「边界只是摆设」。
38+
*/
39+
function balanced(src: string, i: number, tag: string): string | null {
40+
const open = `<${tag}>`, close = `</${tag}>`;
41+
if (!src.startsWith(open, i)) return null;
42+
let d = 0, p = i + open.length;
43+
while (p < src.length) {
44+
const end = src.indexOf(close, p);
45+
if (end < 0) return null;
46+
const o = src.indexOf(open, p);
47+
if (o >= 0 && o < end) { d++; p = o + open.length; } else { if (d === 0) return src.slice(i + open.length, end); d--; p = end + close.length; }
48+
}
49+
return null;
50+
}
3051
function silenced(fn: () => void) {
3152
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
3253
try { fn(); } finally { spy.mockRestore(); }
@@ -53,6 +74,12 @@ describe("叶级错误边界 SlotErrorBoundary", () => {
5374
expect(screen.getByTestId("child-marker"), "阳性对照:正常态本来该挂载").toBeTruthy();
5475
silenced(() => rerender(<Wrap bad />));
5576
expect(screen.getByRole("alert").textContent).toContain("加载失败");
77+
// 文案判据(T13 评审 Minor-3):卡片由**四处**共用 —— `PageSlot`(主窗,有导航)· `?overlay=1` ·
78+
// `?float=1` · dock。后两者是**独立窗口**(`App.tsx` 顶部两个早返回,窗内没有顶栏/没有页面切换)
79+
// ⇒ 旧文案「可切换页面继续使用」在其中**是假陈述**;新文案必须在四处都成立,且不许回退。
80+
const card = screen.getByRole("alert").textContent ?? "";
81+
expect(card, "失败卡文案被改动(四处共用的措辞已冻结)").toBe("此处加载失败——其余区域仍可使用;重启应用可恢复。");
82+
expect(card, "窗口变体里没有页面切换 ⇒ 不许承诺「可切换页面」").not.toContain("\u53ef\u5207\u6362\u9875\u9762");
5683
expect(screen.queryByTestId("child-marker"), "children 仍挂着 ⇒ 没被边界卸载").toBeNull();
5784
expect(screen.queryByTestId("shell-fallback"), "失败态误用了加载态").toBeNull();
5885
});
@@ -97,7 +124,7 @@ describe("结构尺:App.tsx 的边界**按槽位逐个判**(不用全文计
97124
}
98125
});
99126

100-
it("② 页级定义包住 children;overlay / float / dock 三处各一个边界", () => {
127+
it("② 页级定义包住 children;overlay / float / dock 三处各有一个**包住面板**的边界", () => {
101128
const at = (s: string) => APP.indexOf(s);
102129
const ps = at("function PageSlot(");
103130
const def = APP.slice(ps, APP.indexOf("\n}\n", ps));
@@ -107,13 +134,30 @@ describe("结构尺:App.tsx 的边界**按槽位逐个判**(不用全文计
107134
expect(def, "页级定义没把边界包在 children 之外(M-1 的缺陷形态)").toMatch(
108135
/<SlotErrorBoundary>\s*<Suspense fallback=\{<ShellFallback \/>\}>\{children\}<\/Suspense>\s*<\/SlotErrorBoundary>/,
109136
);
137+
// 三段非槽位 Suspense:判**边界的内层**(而非「本段出现过这两个 token」)—— 边界与面板解耦时
138+
// (评审 M11 形态:边界只包一个空 `<span />`、面板留在边界外)必须红。
139+
const PANELS = { overlay: "CaptureOverlayPanel", float: "CaptureFloatPanel", dock: "AiConversationDock" } as const;
140+
/** 边界内层应有的形状:加载态 + **自己那个**面板(只判开标签 —— dock 的面板属性是多行的)。
141+
* 空白用 `\\s+` 吃掉(缩进/换行不是语义)⇒ 只对「面板不在边界内 / 面板名写岔」变红。 */
142+
const shape = (panel: string) =>
143+
new RegExp(`<Suspense\\s+fallback=\\{<ShellFallback\\s*/>\\}\\s*>\\s*<${panel}\\b`);
110144
for (const [name, seg] of [["overlay", overlay], ["float", float], ["dock", dock]] as const) {
111145
expect(seg.length, `${name} 切片为空(定位器写错?)`).toBeGreaterThan(40);
112-
expect(seg, `${name} 分支缺叶级边界`).toContain("<SlotErrorBoundary>");
113-
expect(seg, `${name} 分支缺首访加载态`).toContain("fallback={<ShellFallback />}");
146+
const bl = seg.indexOf("<SlotErrorBoundary>");
147+
const inner = bl < 0 ? null : balanced(seg, bl, "SlotErrorBoundary");
148+
expect(inner === null, `${name} 分支缺叶级边界,或边界没包住自己的面板(边界成了摆设)`).toBe(false);
149+
expect(inner, `${name} 的边界内层不是「加载态 + 自己的面板」`).toMatch(shape(PANELS[name]));
150+
// 闭合顺序(独立于上面的内层判据):`</Suspense>` 必须早于**该边界自己的** `</SlotErrorBoundary>`
151+
expect(seg.indexOf("</Suspense>", seg.indexOf("<Suspense", bl)), `${name} 的边界闭合早于 Suspense`).toBeLessThan(
152+
seg.indexOf("</SlotErrorBoundary>", bl),
153+
);
114154
}
115155
// float:CaptureStatusProvider 必须仍在边界**外层**(「每窗恰一个实例」的采集状态源)
116-
expect(float.indexOf("<CaptureStatusProvider>"), "采集状态源被塞进边界内层了").toBeLessThan(float.indexOf("<SlotErrorBoundary>"));
156+
const pAt = float.indexOf("<CaptureStatusProvider>");
157+
const outer = pAt < 0 ? null : balanced(float, pAt, "CaptureStatusProvider");
158+
expect(outer === null, "采集状态源整段缺失(旧判据在这里是空真:indexOf 返回 -1)").toBe(false);
159+
expect(outer, "采集状态源的内层没有边界").toContain("<SlotErrorBoundary>");
160+
expect(pAt, "采集状态源被塞进边界内层了").toBeLessThan(float.indexOf("<SlotErrorBoundary>"));
117161
expect(APP, "仍有 fallback={null} 残留").not.toMatch(/fallback=\{\s*null\s*\}/);
118162
});
119163
});

‎app/src/shell/ShellFallback.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class SlotErrorBoundary extends Component<{ children: ReactNode }, { fail
5151
data-testid="slot-error"
5252
style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 80, padding: 24, fontSize: 12, color: "#b91c1c" }}
5353
>
54-
此处加载失败——可切换页面继续使用;重启应用可恢复。
54+
此处加载失败——其余区域仍可使用;重启应用可恢复。
5555
</div>
5656
);
5757
}

0 commit comments

Comments
 (0)