1111 * ② 六个文件都从 **barrel**(`"../ui/primitives"`)导入 `ConfirmDialog`(深导入会漏 `motion.css`);
1212 * ③ 每个文件都传了 `impacts`(§5.3 硬要求)与 `busy`(重复点击门);
1313 * ④ 全仓 `app/src/**`(含测试)`window.confirm(` 的剩余命中 == 登记的例外集(预期**空**);
14- * ⑤ 逐处渲染:文案片段按序逐字 + 确认分支执行**同一个** invoke 实参 + 取消分支**不执行**
15- * (= `window.confirm` 返回 false 的那条路径;`NotePreviewView` 的取消还有副作用,单判) 。
14+ * ⑤ 逐处渲染:文案按 DOM 边界**逐段恰等**(标题 / message / 逐条 impact / 取消 / 确认,见 `segmentsOf`)+ 确认
15+ * 分支执行**同一个** invoke 实参 + 取消分支 (= `window.confirm` 返回 false 的那条)**不执行**;副作用单判 。
1616 *
1717 * 边界(诚实登记):
18- * ① 片段判据只认**静态字面片段**;运行时插值(目标名 / 碎片正文 / 段数 / 版本时间)单独用包含式
19- * 断言,且 `VersionPanel` 的时间戳走 `fmtTime`(**本地时区**)⇒ 故意不比字面量。
20- * ② 「逐字」的可机械部分 = 原模板切成片段后**全部按序出现**;句末 `。?`、换行、包裹括号按
21- * **分隔符**处理(映射为标题 / 清单项 / 按钮边界)—— 逐处映射表见 T11 报告 §2。
18+ * ① 段判据只认**静态字面段**;运行时插值(目标名 / 碎片正文 / 段数)在夹具下都取定值 ⇒ 仍是字面段,
19+ * 唯独 `VersionPanel` 的时间戳走 `fmtTime`(**本地时区**)⇒ 那一段单判「前后缀恰等 + 时间形状」。
20+ * ② 「逐字」的可机械部分 = 原模板切成「标题 / message / impact 行 / 按钮」后**逐段恰等** —— 不是
21+ * 「片段存在」(评审 I-1:子串判据下**改一个字 / 追加「(已改字)」全绿**,计划 M4 却要求必红);
22+ * 句末 `。?`、换行、包裹括号按**分隔符**处理(逐处映射表见 T11 报告 §2),未记账字符由总长守卫兜住。
2223 * ③ 不判观感(jsdom 不排版);不判真机 WebView2(`window.confirm` 静默 false 的复现面)。
2324 */
2425import { cleanup , fireEvent , render , screen , waitFor , within } from "@testing-library/react" ;
@@ -60,20 +61,29 @@ const stripComments = (t: string): string =>
6061const read = ( rel : string ) : string =>
6162 stripComments ( readFileSync ( join ( SRC , ...rel . split ( "/" ) ) , "utf8" ) . replace ( / \r \n / g, "\n" ) ) ;
6263
63- /** 片段判据:全部片段按**原顺序**出现 ⇒ 返回缺失/乱序者(空数组 = 通过) */
64- function missingFragments ( text : string , fragments : readonly string [ ] ) : string [ ] {
65- const missing : string [ ] = [ ] ;
66- let at = - 1 ;
67- for ( const frag of fragments ) {
68- const i = text . indexOf ( frag , at + 1 ) ;
69- if ( i < 0 ) missing . push ( frag ) ;
70- else at = i ;
71- }
72- return missing ;
73- }
7464const textOf = ( testId : string ) : string => screen . getByTestId ( testId ) . textContent ?? "" ;
7565const click = ( testId : string ) : void => { fireEvent . click ( screen . getByTestId ( testId ) ) ; } ;
7666
67+ /**
68+ * 弹层按 **DOM 边界**切段(顺序 = 标题 · message · 逐条 impact · 取消 · 确认),供 `toEqual` **逐段恰等**
69+ * —— 这就是 I-1 的牙:段数、顺序、每段字面量全恰等,「改一个字 / 追加一句」必红。⚠️ 原语侧装饰(关闭钮 +
70+ * 印章)**不入段**(不属 T11 迁的文案),但由**总长守卫**单独记账 ⇒ 任何未记账字符同样必红。
71+ */
72+ function segmentsOf ( testId : string ) : string [ ] {
73+ const panel = screen . getByTestId ( testId ) ;
74+ const q = ( sel : string ) : Element | null => panel . querySelector ( sel ) ;
75+ const body = q ( ".ed-confirm" ) ;
76+ const seal = q ( ".ed-confirm-seal" ) ;
77+ const ul = q ( "ul.ed-confirm-impacts" ) ;
78+ const kids = body === null ? [ ] : Array . from ( body . children ) . filter ( ( c ) => c !== seal && c !== ul ) ;
79+ const lis = ul === null ? [ ] : Array . from ( ul . querySelectorAll ( "li" ) ) ;
80+ const chrome = `${ q ( ".ed-modal-head > button" ) ?. textContent ?? "" } ${ seal ?. textContent ?? "" } ` ;
81+ const segs = [ q ( ".ed-modal-head > div" ) , ...kids , ...lis ,
82+ screen . getByTestId ( `${ testId } -cancel` ) , screen . getByTestId ( `${ testId } -confirm` ) ] . map ( ( n ) => n ?. textContent ?? "" ) ;
83+ expect ( panel . textContent ?. length , `弹层里有未记账的字符:${ panel . textContent } ` ) . toBe ( segs . join ( "" ) . length + chrome . length ) ;
84+ return segs ;
85+ }
86+
7787const PROVIDER = {
7888 id : "p1" , name : "DeepSeek" , kind : "openAiCompat" , baseUrl : "https://api.deepseek.com/v1" ,
7989 models : [ "deepseek-flash" ] , defaultModel : "deepseek-flash" , enabled : true , fallbackOrder : [ ] , isDefault : false , hasKey : true ,
@@ -181,11 +191,16 @@ describe("④ 全仓剩余 window.confirm 命中 == 例外集(预期空)", (
181191 } ) ;
182192} ) ;
183193
184- describe ( "⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐字" , ( ) => {
185- it ( "片段判据仪器自检:乱序与缺失都必须被报出来(防自引用恒真)" , ( ) => {
186- expect ( missingFragments ( "甲。乙?丙" , [ "甲" , "乙" , "丙" ] ) ) . toEqual ( [ ] ) ;
187- expect ( missingFragments ( "甲。乙?丙" , [ "甲" , "丙" , "乙" ] ) , "乱序未被报出" ) . toEqual ( [ "乙" ] ) ;
188- expect ( missingFragments ( "甲。乙?丙" , [ "甲" , "丁" ] ) , "缺失未被报出" ) . toEqual ( [ "丁" ] ) ;
194+ describe ( "⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐段恰等" , ( ) => {
195+ it ( "段切分仪器自检:原语装饰不入段 · 总长守卫把「关闭」+ 印章单独记账(防恒真)" , async ( ) => {
196+ const { container } = render ( < BackupPanel /> ) ;
197+ openMock . mockResolvedValue ( "C:/b.zip" ) ;
198+ fireEvent . click ( within ( container ) . getByText ( "从备份恢复…" ) ) ;
199+ await screen . findByTestId ( "backup-restore-confirm" ) ;
200+ const segs = segmentsOf ( "backup-restore-confirm" ) ;
201+ expect ( segs , "少切 / 多切了段(切分仪器坏了)" ) . toHaveLength ( 4 ) ;
202+ expect ( segs . join ( "" ) , "切分没按 DOM 边界:原语装饰混进了段" ) . not . toContain ( "慎" ) ;
203+ expect ( textOf ( "backup-restore-confirm" ) . length , "总长守卫的装饰项不是「关闭」+「慎」这 3 个字符" ) . toBe ( segs . join ( "" ) . length + 3 ) ;
189204 } ) ;
190205
191206 it ( "AiProviderSettings:删 Provider / 清密钥(2 处)" , async ( ) => {
@@ -194,16 +209,12 @@ describe("⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐字", () =>
194209 const trigger = ( label : string ) : void => { fireEvent . click ( within ( container ) . getByText ( label ) ) ; } ;
195210
196211 trigger ( "删除" ) ;
197- expect ( missingFragments ( textOf ( "ai-provider-danger-confirm" ) , [
198- "确定删除?" , "删除后该 Provider 配置与密钥将永久清除,且不可恢复。" , "其它 Provider 不受影响" ,
199- ] ) ) . toEqual ( [ ] ) ;
212+ expect ( segmentsOf ( "ai-provider-danger-confirm" ) ) . toEqual ( [ "确定删除?" , "删除后该 Provider 配置与密钥将永久清除,且不可恢复。" , "Provider 配置与密钥将永久清除" , "其它 Provider 不受影响" , "取消" , "删除" ] ) ;
200213 click ( "ai-provider-danger-confirm-cancel" ) ;
201214 expect ( invokeMock ) . not . toHaveBeenCalledWith ( "ai_provider_remove" , expect . anything ( ) ) ;
202215
203216 trigger ( "清除密钥" ) ;
204- expect ( missingFragments ( textOf ( "ai-provider-danger-confirm" ) , [
205- "确定清除?" , "清除后密钥不可恢复(需重新输入)。" , "端点与模型列表保留" ,
206- ] ) ) . toEqual ( [ ] ) ;
217+ expect ( segmentsOf ( "ai-provider-danger-confirm" ) ) . toEqual ( [ "确定清除?" , "清除后密钥不可恢复(需重新输入)。" , "端点与模型列表保留" , "取消" , "清除" ] ) ;
207218 click ( "ai-provider-danger-confirm-cancel" ) ;
208219 expect ( invokeMock ) . not . toHaveBeenCalledWith ( "ai_provider_clear_key" , expect . anything ( ) ) ;
209220
@@ -220,9 +231,7 @@ describe("⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐字", () =>
220231 openMock . mockResolvedValue ( "C:/b.zip" ) ;
221232 fireEvent . click ( within ( container ) . getByText ( "从备份恢复…" ) ) ;
222233 await screen . findByTestId ( "backup-restore-confirm" ) ;
223- expect ( missingFragments ( textOf ( "backup-restore-confirm" ) , [
224- "恢复将覆盖当前全部数据" , "现有数据库改名 .pre-restore 兜底" , "继续" ,
225- ] ) ) . toEqual ( [ ] ) ;
234+ expect ( segmentsOf ( "backup-restore-confirm" ) ) . toEqual ( [ "恢复将覆盖当前全部数据" , "现有数据库改名 .pre-restore 兜底" , "取消" , "继续" ] ) ;
226235 click ( "backup-restore-confirm-cancel" ) ;
227236 expect ( invokeMock ) . not . toHaveBeenCalledWith ( "backup_restore" , expect . anything ( ) ) ;
228237 // 再选一次并确认 ⇒ 同一条 invoke(实参 = 选中的路径)
@@ -237,9 +246,7 @@ describe("⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐字", () =>
237246 const { container, unmount } = render ( < GoalDetail goalId = { 7 } onChanged = { vi . fn ( ) } onDeleted = { onDeleted } /> ) ;
238247 await screen . findByTestId ( "goal-detail" ) ;
239248 fireEvent . click ( within ( container ) . getByTestId ( "goal-delete-open" ) ) ;
240- expect ( missingFragments ( textOf ( "goal-delete-confirm" ) , [
241- "确定删除目标「高数」?" , "里程碑与绑定将一并移除" , "组本身不受影响" ,
242- ] ) ) . toEqual ( [ ] ) ;
249+ expect ( segmentsOf ( "goal-delete-confirm" ) ) . toEqual ( [ "确定删除目标「高数」?" , "里程碑与绑定将一并移除" , "组本身不受影响" , "取消" , "删除" ] ) ;
243250 click ( "goal-delete-confirm-cancel" ) ;
244251 expect ( invokeMock ) . not . toHaveBeenCalledWith ( "delete_goal" , expect . anything ( ) ) ;
245252 fireEvent . click ( within ( container ) . getByTestId ( "goal-delete-open" ) ) ;
@@ -253,9 +260,7 @@ describe("⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐字", () =>
253260 const g = render ( < GoalDetail goalId = { 8 } onChanged = { vi . fn ( ) } onDeleted = { vi . fn ( ) } /> ) ;
254261 await screen . findByTestId ( "goal-detail" ) ;
255262 fireEvent . click ( within ( g . container ) . getByTestId ( "goal-delete-open" ) ) ;
256- expect ( missingFragments ( textOf ( "goal-delete-confirm" ) , [
257- "确定删除已毕业目标「高数」?" , "毕业报告快照仍会在「毕业档案」保留" ,
258- ] ) ) . toEqual ( [ ] ) ;
263+ expect ( segmentsOf ( "goal-delete-confirm" ) ) . toEqual ( [ "确定删除已毕业目标「高数」?" , "毕业报告快照仍会在「毕业档案」保留" , "取消" , "删除" ] ) ;
259264 expect ( textOf ( "goal-delete-confirm" ) . includes ( "里程碑与绑定将一并移除" ) , "已毕业分支串了未毕业文案" ) . toBe ( false ) ;
260265 click ( "goal-delete-confirm-cancel" ) ;
261266 expect ( invokeMock ) . not . toHaveBeenCalledWith ( "delete_goal" , expect . anything ( ) ) ;
@@ -266,10 +271,7 @@ describe("⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐字", () =>
266271 const trigger = ( ) : void => { fireEvent . click ( within ( container ) . getByText ( "✨ AI 复核" ) ) ; } ;
267272 await waitFor ( ( ) => expect ( within ( container ) . queryByText ( "✨ AI 复核" ) ) . not . toBeNull ( ) ) ;
268273 trigger ( ) ;
269- expect ( missingFragments ( textOf ( "ai-review-confirm" ) , [
270- "是否继续?" , "将发送 1 段边界文本至 DeepSeek(模型 deepseek-flash)进行删除/保留/合并判定。" ,
271- "进行删除/保留/合并判定" , "纯规则结果原样输出" ,
272- ] ) ) . toEqual ( [ ] ) ;
274+ expect ( segmentsOf ( "ai-review-confirm" ) ) . toEqual ( [ "是否继续?" , "将发送 1 段边界文本至 DeepSeek(模型 deepseek-flash)进行删除/保留/合并判定。" , "进行删除/保留/合并判定" , "纯规则结果原样输出" , "取消" , "继续" ] ) ;
273275 click ( "ai-review-confirm-cancel" ) ;
274276 expect ( invokeMock ) . not . toHaveBeenCalledWith ( "review_text_filter" , expect . anything ( ) ) ;
275277 // 取消路径的**副作用**(原 window.confirm 返回 false 的那条分支):状态文案必须还在
@@ -285,9 +287,9 @@ describe("⑤ 逐处渲染:确认 / 取消两条分支 + 文案逐字", () =>
285287 fireEvent . click ( within ( container ) . getByText ( / 版 本 时 间 线 / ) ) ;
286288 const trigger = await within ( container ) . findByText ( "回滚到此处" ) ;
287289 fireEvent . click ( trigger ) ;
288- expect ( missingFragments ( textOf ( "version-rollback-confirm" ) , [
289- "回滚到「本地规则」版本(" , ")? " , "将创建新版本 " , "历史链保留" ,
290- ] ) ) . toEqual ( [ ] ) ;
290+ const segs = segmentsOf ( "version-rollback-confirm" ) ;
291+ expect ( segs . slice ( 1 ) , "第 0 段是运行时插值标题(`fmtTime` 走本地时区)" ) . toEqual ( [ "将创建新版本" , "历史链保留 " , "取消 " , "回滚" ] ) ;
292+ expect ( segs [ 0 ] ) . toMatch ( / ^ 回 滚 到 「 本 地 规 则 」 版 本 ( \d { 4 } - \d { 2 } - \d { 2 } \d { 2 } : \d { 2 } ) ? $ / ) ;
291293 click ( "version-rollback-confirm-cancel" ) ;
292294 expect ( invokeMock ) . not . toHaveBeenCalledWith ( "note_versions_rollback" , expect . anything ( ) ) ;
293295 fireEvent . click ( within ( container ) . getByText ( "回滚到此处" ) ) ;
0 commit comments