|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Modal.exit.test.tsx —— **退场窗口的可点性契约**(批 0-D Task 7 追加;T8 评审 I-1 的 |
| 4 | + * 系统级修法)。 |
| 5 | + * |
| 6 | + * Why:`open=false` 之后弹层仍会挂载 160ms(`[data-phase="exit"]`,`usePresence` 的出场窗口)。 |
| 7 | + * T8 的探针实测:这个窗口里面板与遮罩**仍然可点** ⇒ 消费者(`ConfirmDialog`)的「确认 / 删除」 |
| 8 | + * 在退场中会再触发一次回调;批 4 把 28 个手写弹层迁过来之后,这就是**删除类操作的误触面**。 |
| 9 | + * 解法:退场相位统一 `pointer-events: none`(进场相位不动,也不用 `!important`)。 |
| 10 | + * |
| 11 | + * Why 另开一个文件:`Modal.test.tsx` 已 296/300 行,而 ≤300 是硬红线(`line-limits.mjs` 扫 `app/src`)。 |
| 12 | + * |
| 13 | + * Why 判据读 CSS 文本 + DOM 相位:`vitest.config.ts` 的 `css` 默认 false ⇒ 测试环境不加载样式表 |
| 14 | + * (jsdom 也不做命中测试)⇒ `getComputedStyle` 永远读不到 `pointer-events`。故拆成两条互补断言: |
| 15 | + * ① 退场相位**真的**带 `data-phase="exit"` 且面板内容仍在 DOM(= 因果关系的前件); |
| 16 | + * ② `[data-phase="exit"]` 规则**真的**声明了 `pointer-events: none`(= 后件)。两者缺一,本契约即断。 |
| 17 | + * |
| 18 | + * 副作用:无(只挂 React 树 + 读 1 个 CSS 文件);假计时器只在本文件的第一个用例里用。 |
| 19 | + * 边界:不重述 `Modal.test.tsx` 已覆盖的进出场时序(`enter → entered`、兜底 240ms、可反向)。 |
| 20 | + */ |
| 21 | +import { act, cleanup, render, screen } from "@testing-library/react"; |
| 22 | +import { readFileSync } from "node:fs"; |
| 23 | +import { dirname, join } from "node:path"; |
| 24 | +import { fileURLToPath } from "node:url"; |
| 25 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 26 | +import { Modal } from "./Modal"; |
| 27 | + |
| 28 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 29 | +const noop = (): void => undefined; |
| 30 | +/** 剥注释后再判据:文件头的注释里就写着"不使用 !important"(同 `style-seams.test.ts` 的口径) */ |
| 31 | +const CSS = readFileSync(join(HERE, "Modal.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); |
| 32 | + |
| 33 | +/** 取一条 CSS 规则的原文(到第一个 `}` 为止;transition 值里没有花括号) */ |
| 34 | +function rule(selector: string): string { |
| 35 | + const start = CSS.indexOf(selector); |
| 36 | + expect(start, `Modal.css 缺少规则「${selector}」`).toBeGreaterThanOrEqual(0); |
| 37 | + return CSS.slice(start, CSS.indexOf("}", start) + 1); |
| 38 | +} |
| 39 | + |
| 40 | +function Host({ open }: { open: boolean }) { |
| 41 | + return ( |
| 42 | + <Modal open={open} onClose={noop} title="标题文本" testId="m" footer={<button data-testid="ok">确认</button>}> |
| 43 | + 正文内容 |
| 44 | + </Modal> |
| 45 | + ); |
| 46 | +} |
| 47 | + |
| 48 | +afterEach(() => { |
| 49 | + cleanup(); |
| 50 | + vi.useRealTimers(); |
| 51 | +}); |
| 52 | + |
| 53 | +describe("退场窗口:相位前件(没有它,下面那组 CSS 断言就是空转)", () => { |
| 54 | + it("open=false 后 160ms 内面板与遮罩都带 data-phase='exit',且面板内容仍在 DOM 里", () => { |
| 55 | + vi.useFakeTimers(); |
| 56 | + const { rerender } = render(<Host open />); |
| 57 | + act(() => { |
| 58 | + vi.advanceTimersByTime(0); // enter → entered 要等一个宏任务(见 usePresence 的 ENTER_TICK_MS) |
| 59 | + }); |
| 60 | + expect(screen.getByRole("dialog").getAttribute("data-phase")).toBe("entered"); |
| 61 | + rerender(<Host open={false} />); |
| 62 | + |
| 63 | + expect(screen.getByRole("dialog").getAttribute("data-phase")).toBe("exit"); |
| 64 | + expect(screen.getByTestId("m-overlay").getAttribute("data-phase"), "遮罩是另一条规则的锚点").toBe("exit"); |
| 65 | + expect(screen.getByTestId("ok"), "按钮仍在 DOM 里 ⇒ 没禁指针事件时它真的可点").not.toBeNull(); |
| 66 | + act(() => { |
| 67 | + vi.advanceTimersByTime(239); // 兜底窗口未到 ⇒ 仍在退场相位 |
| 68 | + }); |
| 69 | + expect(screen.getByRole("dialog").getAttribute("data-phase")).toBe("exit"); |
| 70 | + }); |
| 71 | +}); |
| 72 | + |
| 73 | +describe("退场相位禁指针事件(面板 + 遮罩各一条,进场相位不动)", () => { |
| 74 | + it("两条 exit 规则都声明 pointer-events: none", () => { |
| 75 | + expect(rule('.ed-modal-overlay[data-phase="exit"]')).toContain("pointer-events: none;"); |
| 76 | + expect(rule('.ed-modal[data-phase="exit"]')).toContain("pointer-events: none;"); |
| 77 | + }); |
| 78 | + |
| 79 | + it("反例守门:进场相位(enter / entered)不得出现 pointer-events(否则弹层一开始就点不动)", () => { |
| 80 | + expect(rule('.ed-modal-overlay[data-phase="enter"]')).not.toContain("pointer-events"); |
| 81 | + expect(rule('.ed-modal[data-phase="enter"]')).not.toContain("pointer-events"); |
| 82 | + expect(rule('.ed-modal[data-phase="entered"]')).not.toContain("pointer-events"); |
| 83 | + expect(rule(".ed-modal-overlay {")).not.toContain("pointer-events"); |
| 84 | + expect(rule(".ed-modal {")).not.toContain("pointer-events"); |
| 85 | + }); |
| 86 | + |
| 87 | + it("Modal.css 里 pointer-events 恰好出现 2 次(只在两条 exit 规则里),且全文件无 !important", () => { |
| 88 | + expect(CSS.match(/pointer-events/g) ?? []).toHaveLength(2); |
| 89 | + expect(CSS).not.toContain("!important"); |
| 90 | + }); |
| 91 | +}); |
0 commit comments