Skip to content

Commit ebac9d7

Browse files
committed
test(ui): pin exact confirm copy and baseline sum
1 parent f6dd012 commit ebac9d7

2 files changed

Lines changed: 60 additions & 45 deletions

File tree

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

Lines changed: 46 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
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
*/
2425
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
@@ -60,20 +61,29 @@ const stripComments = (t: string): string =>
6061
const 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-
}
7464
const textOf = (testId: string): string => screen.getByTestId(testId).textContent ?? "";
7565
const 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+
7787
const 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("回滚到此处"));

‎app/src/ui/primitives/buttonMigration.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { readFileSync } from "node:fs";
2525
import { dirname, join } from "node:path";
2626
import { fileURLToPath } from "node:url";
2727
import { describe, expect, it } from "vitest";
28-
import { FROZEN_NATIVE_BUTTON_TOTAL } from "./nativeButtonBaseline";
28+
import { FROZEN_NATIVE_BUTTON_BY_FILE, FROZEN_NATIVE_BUTTON_TOTAL } from "./nativeButtonBaseline";
2929
import { declsOf, directHits, shapeCensus, stripComments } from "./buttonScan";
3030

3131
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -259,6 +259,19 @@ describe("④ 棘轮朝向 + 缺口可见(这条判据的牙)", () => {
259259
expect(FROZEN_NATIVE_BUTTON_TOTAL, `棘轮基线被抬高到 ${FROZEN_NATIVE_BUTTON_TOTAL}(T12 迁移前是 ${PRE_T12_NATIVE_BUTTONS})`).toBeLessThanOrEqual(PRE_T12_NATIVE_BUTTONS);
260260
});
261261

262+
/**
263+
* 评审 M-1:上面那条只判 `FROZEN ≤ 493`,把常量手改成 395 / 493 / 510 **三次全绿**(报告却写「改回
264+
* 510 ⇒ 红」)⇒ 牙只在一侧。这条把常量钉回**逐文件 entries 之和**(T12 实测 114 键 / sum 394 / 常量
265+
* 394,逐条复算过)—— 只改常量 = 悄悄腾出预算,在这里必红,不必等新代码回潮。
266+
* ⚠️ 不改棘轮本身的 `<=` 语义(那会与 `SPLIT_MOVES` 的拆件守恒冲突);拆件登记新键时同步键数锚。
267+
*/
268+
it("基线常量 == 逐文件 entries 之和(只改常量腾预算必红)", () => {
269+
const entries = Object.entries(FROZEN_NATIVE_BUTTON_BY_FILE);
270+
const sum = entries.reduce((acc, [, n]) => acc + n, 0);
271+
expect(entries.length, "逐文件基线键数变了(T12 实测 114 键;拆件走 SPLIT_MOVES 时同步这里)").toBe(114);
272+
expect(FROZEN_NATIVE_BUTTON_TOTAL, `基线常量 ${FROZEN_NATIVE_BUTTON_TOTAL} ≠ 逐文件之和 ${sum}`).toBe(sum);
273+
});
274+
262275
it("两个 B1 例外文件不许 import 原语(与 T8 的 dialogMigration.e.test.ts:240-244 同向复核)", () => {
263276
const offenders = ["components/GroupRowContextMenu.tsx", "components/RichEditorView.tsx"]
264277
.filter((rel) => read(rel).includes("ui/primitives"));

0 commit comments

Comments
 (0)