|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context ConfirmDialog.test.tsx —— 危险确认的**语义契约**测试(批 0-D Task 8;规格 §5.3)。 |
| 4 | + * |
| 5 | + * Why jsdom:弹层经 `Modal` 走 portal 且带焦点陷阱 ⇒ 要真容器与 `document.activeElement`。 |
| 6 | + * 本文件钉住四件事:① §5.3 的硬要求「框内必须列明级联影响与保留项」;② **危险语义用色** |
| 7 | + * (控制方 2026-09-11 裁决③ · §4.1「绝不用于按钮」)—— 确认按钮保持中性,`--ed-stamp` 绝不出现在 |
| 8 | + * 任何 `background*` 声明里(与 Task 14 Step 1 第 4 条的批次守卫**同向**);③ **复用 Modal 内核**: |
| 9 | + * 直接扫 `ConfirmDialog.tsx` 的源码作机器证据(含 `from "./Modal"`、不含 Portal 与层级标尺的符号), |
| 10 | + * 并断言本原语 CSS 无层级、无定位;④ 「可中断 / 可反向 / 重复触发」的确定行为(§8.6.1 第 3 条)。 |
| 11 | + * |
| 12 | + * 样式判据读文本:`vitest.config.ts` 的 `css` 为 false(测试环境不加载样式表)⇒ 范式 C(同 Modal.test.tsx)。 |
| 13 | + * 副作用:无(只挂 React 树 + 读三个文本文件);假计时器只用在推进 presence 计时的那个 describe。 |
| 14 | + * 边界:不用 jest-dom(本仓未装)—— 一律 `getAttribute` / `textContent` / `document.activeElement`。 |
| 15 | + */ |
| 16 | +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; |
| 17 | +import { readFileSync } from "node:fs"; |
| 18 | +import { dirname, join } from "node:path"; |
| 19 | +import { fileURLToPath } from "node:url"; |
| 20 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 21 | +import { ConfirmDialog } from "./ConfirmDialog"; |
| 22 | +import type { ConfirmDialogProps, ConfirmImpact } from "./ConfirmDialog"; |
| 23 | + |
| 24 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 25 | +/** 归一 EOL(本仓无 `.gitattributes` 且 `core.autocrlf=true`,逐字节断言会在别人机器上假阳性) */ |
| 26 | +const readText = (n: string): string => readFileSync(join(HERE, n), "utf8").replace(/\r\n/g, "\n"); |
| 27 | +const CONFIRM_TSX = readText("ConfirmDialog.tsx"); |
| 28 | +const CONFIRM_CSS = readText("ConfirmDialog.css"); |
| 29 | +const BUTTON_CSS = readText("Button.css"); |
| 30 | +const noop = (): void => {}; |
| 31 | + |
| 32 | +/** 规格 §5.3 的范例:级联删除项 + 保留项(保留项必须与删除项一眼可分) */ |
| 33 | +const IMPACTS: readonly ConfirmImpact[] = [ |
| 34 | + { text: "将删除 1 个组" }, |
| 35 | + { text: "3 条排序记录" }, |
| 36 | + { text: "笔记 7 篇保留", keep: true }, |
| 37 | +]; |
| 38 | + |
| 39 | +/** 宿主 props:派生自原语契约(每个用例只关心其中几项 ⇒ `Partial` + 必需的 `open`) */ |
| 40 | +type HostProps = Partial<ConfirmDialogProps> & { open: boolean }; |
| 41 | + |
| 42 | +/** 宿主:受控形态(与真实调用点一致,`open` 由测试 rerender 驱动) */ |
| 43 | +function Host({ open, title = "删除「高数」?", onConfirm = noop, onCancel = noop, ...rest }: HostProps) { |
| 44 | + return <ConfirmDialog open={open} title={title} onConfirm={onConfirm} onCancel={onCancel} testId="cd" {...rest} />; |
| 45 | +} |
| 46 | + |
| 47 | +const panel = (): HTMLElement => screen.getByRole("dialog"); |
| 48 | +const panelOrNull = (): Element | null => document.body.querySelector('[role="dialog"]'); |
| 49 | +const confirmBtn = (): HTMLElement => screen.getByTestId("cd-confirm"); |
| 50 | +const cancelBtn = (): HTMLElement => screen.getByTestId("cd-cancel"); |
| 51 | +const sealOrNull = (): Element | null => document.body.querySelector(".ed-confirm-seal"); |
| 52 | +const cls = (el: Element): string => el.getAttribute("class") ?? ""; |
| 53 | + |
| 54 | +afterEach(() => { |
| 55 | + cleanup(); |
| 56 | + vi.useRealTimers(); |
| 57 | +}); |
| 58 | + |
| 59 | +describe("① 复用 Modal 内核:本原语不自建第二套 Portal / 层叠 / 键盘", () => { |
| 60 | + it("源码证据:import Modal;不出现 createPortal / zIndex", () => { |
| 61 | + expect(CONFIRM_TSX, "必须消费 Modal(弹层唯一实现)").toContain('from "./Modal"'); |
| 62 | + expect(CONFIRM_TSX, "自建 portal = 第二套弹层实现").not.toContain("createPortal"); |
| 63 | + expect(CONFIRM_TSX, "自建层级 = 绕过 z-index 标尺").not.toContain("zIndex"); |
| 64 | + }); |
| 65 | + |
| 66 | + it("样式证据:ConfirmDialog.css 无 z-index、无 position(自建遮罩的两个特征)", () => { |
| 67 | + expect(CONFIRM_CSS).not.toMatch(/z-index/); |
| 68 | + expect(CONFIRM_CSS).not.toMatch(/position\s*:/); |
| 69 | + }); |
| 70 | + |
| 71 | + it("open=false 不渲染;open=true 时 role=dialog + aria-modal + aria-labelledby==title", () => { |
| 72 | + const { rerender } = render(<Host open={false} />); |
| 73 | + expect(panelOrNull()).toBeNull(); |
| 74 | + rerender(<Host open />); |
| 75 | + expect(panel().getAttribute("aria-modal")).toBe("true"); |
| 76 | + const id = panel().getAttribute("aria-labelledby"); |
| 77 | + expect(document.getElementById(id as string)?.textContent).toBe("删除「高数」?"); |
| 78 | + }); |
| 79 | +}); |
| 80 | + |
| 81 | +describe("② 级联影响清单(§5.3:高危不可逆动作必须列明级联影响与保留项)", () => { |
| 82 | + it("impacts 逐条渲染且顺序不变;keep 项带 `ed-confirm-keep` 类与 `data-keep`", () => { |
| 83 | + render(<Host open impacts={IMPACTS} />); |
| 84 | + const items = screen.getAllByRole("listitem"); |
| 85 | + expect(items.map((li) => li.textContent)).toEqual(["将删除 1 个组", "3 条排序记录", "笔记 7 篇保留"]); |
| 86 | + expect(items[0].getAttribute("class")).toBeNull(); |
| 87 | + expect(items[0].getAttribute("data-keep")).toBeNull(); |
| 88 | + expect(items[2].getAttribute("class")).toBe("ed-confirm-keep"); |
| 89 | + expect(items[2].getAttribute("data-keep")).toBe("true"); |
| 90 | + }); |
| 91 | + |
| 92 | + it("保留项前缀是正向标记(「›」+ --ed-ok),删除项是中性「·」", () => { |
| 93 | + expect(CONFIRM_CSS).toMatch(/li\.ed-confirm-keep::before\s*\{[^}]*content:\s*"›"/); |
| 94 | + expect(CONFIRM_CSS).toMatch(/li\.ed-confirm-keep::before\s*\{[^}]*color:\s*var\(--ed-ok\)/); |
| 95 | + }); |
| 96 | + |
| 97 | + it("不传 impacts / 传空数组 ⇒ 不渲染列表;message 传了才渲染", () => { |
| 98 | + const { rerender } = render(<Host open />); |
| 99 | + expect(screen.queryByRole("list")).toBeNull(); |
| 100 | + expect(screen.queryByText("此操作不可撤销。")).toBeNull(); |
| 101 | + rerender(<Host open impacts={[]} />); |
| 102 | + expect(screen.queryByRole("list")).toBeNull(); |
| 103 | + rerender(<Host open message="此操作不可撤销。" />); |
| 104 | + expect(screen.getByText("此操作不可撤销。")).toBeTruthy(); |
| 105 | + }); |
| 106 | +}); |
| 107 | + |
| 108 | +describe("③ 危险语义用色(控制方裁决③ · 与 Task 14 第 4 条守卫同向)", () => { |
| 109 | + it("两个按钮都是 secondary(中性),确认按钮不得是 primary / danger", () => { |
| 110 | + render(<Host open />); |
| 111 | + for (const btn of [cancelBtn(), confirmBtn()]) { |
| 112 | + expect(cls(btn)).toContain("ed-btn--secondary"); |
| 113 | + expect(cls(btn)).not.toContain("ed-btn--primary"); |
| 114 | + expect(cls(btn)).not.toContain("danger"); |
| 115 | + } |
| 116 | + }); |
| 117 | + |
| 118 | + it("`--ed-stamp` 绝不作底色:任何 background* 声明都不得含它", () => { |
| 119 | + const offenders = CONFIRM_CSS.split("\n").filter( |
| 120 | + (line) => /^\s*background[a-z-]*\s*:/.test(line) && line.includes("var(--ed-stamp)"), |
| 121 | + ); |
| 122 | + expect(offenders, "该 token 出现在 background 声明里(§4.1「绝不用于按钮」)").toEqual([]); |
| 123 | + }); |
| 124 | + |
| 125 | + it("`--ed-stamp` 在本原语里只作文字色与描边(印章标记)", () => { |
| 126 | + const tail = CONFIRM_CSS.slice(CONFIRM_CSS.indexOf(".ed-confirm-seal {")); |
| 127 | + const rule = tail.slice(0, tail.indexOf("}")); |
| 128 | + expect(rule).toMatch(/color:\s*var\(--ed-stamp\)/); |
| 129 | + expect(rule).toMatch(/border:\s*1px solid var\(--ed-stamp\)/); |
| 130 | + }); |
| 131 | + |
| 132 | + it("Button.css 内 `--ed-stamp` 计数为 0(Task 14 同向:按钮层不得出现危险色)", () => { |
| 133 | + expect((BUTTON_CSS.match(/--ed-stamp/g) ?? []).length).toBe(0); |
| 134 | + }); |
| 135 | + |
| 136 | + it("印章标记存在、`aria-hidden`、有可见字符(装饰性;危险语义由清单的**文字**承载)", () => { |
| 137 | + render(<Host open />); |
| 138 | + const seal = sealOrNull(); |
| 139 | + expect(seal, "缺印章标记 ⇒ 危险信号只剩按钮与文案").not.toBeNull(); |
| 140 | + expect(seal?.getAttribute("aria-hidden")).toBe("true"); |
| 141 | + expect((seal?.textContent ?? "").length).toBeGreaterThan(0); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +describe("④ 回调契约(含文案默认值与 testId 透传)", () => { |
| 146 | + it("确认 → onConfirm 一次;取消 → onCancel 一次", () => { |
| 147 | + const onConfirm = vi.fn(); |
| 148 | + const onCancel = vi.fn(); |
| 149 | + render(<Host open onConfirm={onConfirm} onCancel={onCancel} />); |
| 150 | + fireEvent.click(confirmBtn()); |
| 151 | + expect(onConfirm).toHaveBeenCalledTimes(1); |
| 152 | + expect(onCancel).not.toHaveBeenCalled(); |
| 153 | + fireEvent.click(cancelBtn()); |
| 154 | + expect(onCancel).toHaveBeenCalledTimes(1); |
| 155 | + expect(onConfirm).toHaveBeenCalledTimes(1); |
| 156 | + }); |
| 157 | + |
| 158 | + it("默认文案「确认」/「取消」;可逐项自定义", () => { |
| 159 | + const { rerender } = render(<Host open />); |
| 160 | + expect(confirmBtn().textContent).toBe("确认"); |
| 161 | + expect(cancelBtn().textContent).toBe("取消"); |
| 162 | + rerender(<Host open confirmLabel="删除" cancelLabel="再想想" />); |
| 163 | + expect(confirmBtn().textContent).toBe("删除"); |
| 164 | + expect(cancelBtn().textContent).toBe("再想想"); |
| 165 | + }); |
| 166 | +}); |
| 167 | + |
| 168 | +describe("⑤ 退出路径永不产生确认(ESC / 遮罩都走 onCancel)", () => { |
| 169 | + it("ESC → onCancel;长按(repeat)不重复触发;onConfirm 始终 0", () => { |
| 170 | + const onConfirm = vi.fn(); |
| 171 | + const onCancel = vi.fn(); |
| 172 | + render(<Host open onConfirm={onConfirm} onCancel={onCancel} />); |
| 173 | + fireEvent.keyDown(document, { key: "Escape", repeat: true }); |
| 174 | + expect(onCancel, "自动重复的 keydown 不得连关").not.toHaveBeenCalled(); |
| 175 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 176 | + expect(onCancel).toHaveBeenCalledTimes(1); |
| 177 | + expect(onConfirm).not.toHaveBeenCalled(); |
| 178 | + }); |
| 179 | + |
| 180 | + it("点遮罩 → onCancel;面板内 mousedown 不触发(防「面板内按下、遮罩上松开」误关)", () => { |
| 181 | + const onConfirm = vi.fn(); |
| 182 | + const onCancel = vi.fn(); |
| 183 | + render(<Host open onConfirm={onConfirm} onCancel={onCancel} />); |
| 184 | + fireEvent.mouseDown(panel()); |
| 185 | + expect(onCancel).not.toHaveBeenCalled(); |
| 186 | + fireEvent.mouseDown(screen.getByTestId("cd-overlay")); |
| 187 | + expect(onCancel).toHaveBeenCalledTimes(1); |
| 188 | + expect(onConfirm).not.toHaveBeenCalled(); |
| 189 | + }); |
| 190 | +}); |
| 191 | + |
| 192 | +describe("⑥ 可中断 / 可反向 / 重复触发(§8.6.1 第 3 条)", () => { |
| 193 | + it("打开后焦点落在「取消」而非「确认」(危险动作不能因一个回车就发生)", () => { |
| 194 | + render(<Host open impacts={IMPACTS} />); |
| 195 | + expect(document.activeElement, "焦点必须在面板内").toBe(cancelBtn()); |
| 196 | + expect(document.activeElement).not.toBe(confirmBtn()); |
| 197 | + }); |
| 198 | + |
| 199 | + it("Tab 循环仍由 Modal 的陷阱接管:焦点不逃出面板(取消 → 确认 → 回卷)", () => { |
| 200 | + render(<Host open />); |
| 201 | + expect(document.activeElement).toBe(cancelBtn()); |
| 202 | + fireEvent.keyDown(document, { key: "Tab" }); |
| 203 | + expect(document.activeElement, "footer 内顺序:危险在右").toBe(confirmBtn()); |
| 204 | + fireEvent.keyDown(document, { key: "Tab" }); |
| 205 | + expect(panel().contains(document.activeElement), "末元素必须回卷").toBe(true); |
| 206 | + }); |
| 207 | + |
| 208 | + it("busy=true:两个按钮 aria-disabled,各连点 3 次仍 0 回调(重复触发的第一道防线)", () => { |
| 209 | + const onConfirm = vi.fn(); |
| 210 | + const onCancel = vi.fn(); |
| 211 | + render(<Host open busy onConfirm={onConfirm} onCancel={onCancel} />); |
| 212 | + for (const btn of [confirmBtn(), cancelBtn()]) { |
| 213 | + expect(btn.getAttribute("aria-disabled")).toBe("true"); |
| 214 | + for (let i = 0; i < 3; i += 1) fireEvent.click(btn); |
| 215 | + } |
| 216 | + expect(onConfirm).not.toHaveBeenCalled(); |
| 217 | + expect(onCancel).not.toHaveBeenCalled(); |
| 218 | + }); |
| 219 | + |
| 220 | + it("busy 不改变退出语义(确定行为):只有按钮被拦,ESC / 遮罩仍走 onCancel", () => { |
| 221 | + const onConfirm = vi.fn(); |
| 222 | + const onCancel = vi.fn(); |
| 223 | + render(<Host open busy onConfirm={onConfirm} onCancel={onCancel} />); |
| 224 | + fireEvent.keyDown(document, { key: "Escape" }); |
| 225 | + fireEvent.mouseDown(screen.getByTestId("cd-overlay")); |
| 226 | + expect(onCancel).toHaveBeenCalledTimes(2); |
| 227 | + expect(onConfirm).not.toHaveBeenCalled(); |
| 228 | + }); |
| 229 | + |
| 230 | + it("非 busy 时连点确认两次 ⇒ 恰好两次(**不做去抖**:防重复是父级 busy 的职责,见上条)", () => { |
| 231 | + const onConfirm = vi.fn(); |
| 232 | + render(<Host open onConfirm={onConfirm} />); |
| 233 | + fireEvent.click(confirmBtn()); |
| 234 | + fireEvent.click(confirmBtn()); |
| 235 | + expect(onConfirm).toHaveBeenCalledTimes(2); |
| 236 | + }); |
| 237 | +}); |
| 238 | + |
| 239 | +describe("⑦ 进出场时机(presence 由 Modal 承载)", () => { |
| 240 | + beforeEach(() => { |
| 241 | + vi.useFakeTimers(); |
| 242 | + }); |
| 243 | + |
| 244 | + const tick = (ms: number): void => { |
| 245 | + act(() => { |
| 246 | + vi.advanceTimersByTime(ms); |
| 247 | + }); |
| 248 | + }; |
| 249 | + |
| 250 | + it("关闭:先 exit 且仍挂载;面板收到 transitionend ⇒ 立即卸载", () => { |
| 251 | + const { rerender } = render(<Host open />); |
| 252 | + tick(0); |
| 253 | + rerender(<Host open={false} />); |
| 254 | + expect(panel().getAttribute("data-phase")).toBe("exit"); |
| 255 | + const el = panel(); |
| 256 | + fireEvent.transitionEnd(el, { target: el, currentTarget: el }); |
| 257 | + expect(panelOrNull()).toBeNull(); |
| 258 | + }); |
| 259 | + |
| 260 | + it("可反向:出场期间 open 回 true ⇒ 不卸载、回 entered,且不产生任何回调(动效不带动作语义)", () => { |
| 261 | + const onConfirm = vi.fn(); |
| 262 | + const onCancel = vi.fn(); |
| 263 | + const { rerender } = render(<Host open onConfirm={onConfirm} onCancel={onCancel} />); |
| 264 | + tick(0); |
| 265 | + rerender(<Host open={false} onConfirm={onConfirm} onCancel={onCancel} />); |
| 266 | + expect(panel().getAttribute("data-phase")).toBe("exit"); |
| 267 | + rerender(<Host open onConfirm={onConfirm} onCancel={onCancel} />); |
| 268 | + expect(panel().getAttribute("data-phase")).toBe("entered"); |
| 269 | + tick(10_000); |
| 270 | + expect(panelOrNull(), "退场兜底计时器必须已被接管(不排队)").not.toBeNull(); |
| 271 | + expect(onCancel).not.toHaveBeenCalled(); |
| 272 | + expect(onConfirm).not.toHaveBeenCalled(); |
| 273 | + }); |
| 274 | +}); |
0 commit comments