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 */
1620import { readFileSync } from "node:fs" ;
1721import { 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 ⇒ 抑制后还原 */
2933function 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+ }
3051function 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 / < S l o t E r r o r B o u n d a r y > \s * < S u s p e n s e f a l l b a c k = \{ < S h e l l F a l l b a c k \/ > \} > \{ c h i l d r e n \} < \/ S u s p e n s e > \s * < \/ S l o t E r r o r B o u n d a r y > / ,
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 ( / f a l l b a c k = \{ \s * n u l l \s * \} / ) ;
118162 } ) ;
119163} ) ;
0 commit comments