|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context ⌘K 命令面板的契约守卫(规格 §6.1;批 3 Task 11)。 |
| 4 | + * |
| 5 | + * 判据分五层,每层都能**独立变红**(每条判据的变异体实测见 `task-11-report.md` §5): |
| 6 | + * ① 契约:`role="dialog"` + `aria-modal` + 输入框自动聚焦(**与 `Modal` 对齐的五条契约之三**, |
| 7 | + * 另两条 = ESC 关 / 点遮罩关,在 ② 层); |
| 8 | + * ② 关闭路径:Esc 关(window 冒泡相)· 点遮罩关(`mousedown`,与 `Modal` 同款)· 面板内点击**不**关; |
| 9 | + * ③ 键盘导航:↑↓ 改选中项(`aria-selected`)· Enter 执行并关闭 · 过滤后选中位回到首项; |
| 10 | + * ④ IME:**组合中按 Enter 不提交**(中文输入法不能误触)—— 判据自带**阳性对照**(同一条命令 |
| 11 | + * 在不带 `isComposing` 的 Enter 下必须提交),否则「没提交」可能只是「Enter 从来没生效」; |
| 12 | + * ⑤ 静态纪律:9 个页面跳转命令来自注册表(不硬编码 label)· 不 import 原语层 · 层级走 `zIndex()` |
| 13 | + * 且无裸数字 · CSS 无 transition/animation(动效属批 6)。 |
| 14 | + * |
| 15 | + * ⚠️ 本仓测试底座(照抄勿改):`vitest.config.ts` 全局 `environment: "node"` ⇒ 首行必须 |
| 16 | + * `// @vitest-environment jsdom`;**未装** `jest-dom` 与 `user-event` ⇒ 断言用原生 DOM API、 |
| 17 | + * 交互用 `fireEvent`;**没有** `globals` ⇒ RTL 的 auto-cleanup 不生效,必须显式 `afterEach(cleanup)`。 |
| 18 | + */ |
| 19 | +import { readFileSync } from "node:fs"; |
| 20 | +import { dirname, join } from "node:path"; |
| 21 | +import { fileURLToPath } from "node:url"; |
| 22 | +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; |
| 23 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 24 | +import { CommandPalette } from "./CommandPalette"; |
| 25 | +import { ALL_ENTRIES } from "./navRegistry"; |
| 26 | + |
| 27 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 28 | +const TSX = readFileSync(join(HERE, "CommandPalette.tsx"), "utf8"); |
| 29 | +/** 判据前先剥注释(与 `TopBar.test.tsx:34` 同一口径):文件头解释「不得出现 transition」时点到了那个词 */ |
| 30 | +const CSS = readFileSync(join(HERE, "CommandPalette.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); |
| 31 | +/** `App.tsx` 的**只留代码**版本(剥块注释 + 整行 `//`)—— 与 `TopBar.test.tsx:43` 同一口径 */ |
| 32 | +const APP_CODE = readFileSync(join(HERE, "..", "App.tsx"), "utf8") |
| 33 | + .replace(/\/\*[\s\S]*?\*\//g, "") |
| 34 | + .replace(/^\s*\/\/.*$/gm, ""); |
| 35 | + |
| 36 | +afterEach(cleanup); |
| 37 | + |
| 38 | +const base = { open: true, onClose: vi.fn(), onPick: vi.fn() }; |
| 39 | + |
| 40 | +describe("CommandPalette 契约(规格 §6.1)", () => { |
| 41 | + it("① role=dialog + aria-modal + 输入框自动聚焦(批 4 迁移到 Modal 前的契约对齐)", () => { |
| 42 | + render(<CommandPalette {...base} />); |
| 43 | + const dialog = screen.getByTestId("command-palette"); |
| 44 | + expect(dialog.getAttribute("role")).toBe("dialog"); |
| 45 | + expect(dialog.getAttribute("aria-modal")).toBe("true"); |
| 46 | + expect(dialog.getAttribute("aria-label")).toBe("命令面板"); |
| 47 | + // 焦点:面板打开即落在输入框(键盘用户不必先按 Tab 找入口) |
| 48 | + expect(document.activeElement).toBe(screen.getByTestId("command-palette-input")); |
| 49 | + }); |
| 50 | + |
| 51 | + it("① open=false 渲染 null(关闭后不留遮罩/焦点陷阱)", () => { |
| 52 | + const { container } = render(<CommandPalette {...base} open={false} />); |
| 53 | + expect(container.firstChild).toBeNull(); |
| 54 | + }); |
| 55 | + |
| 56 | + it("② Esc 关闭;关闭后 Esc 不再触发(监听随 open 解绑,避免常驻 window 监听)", () => { |
| 57 | + const onClose = vi.fn(); |
| 58 | + const { unmount } = render(<CommandPalette {...base} onClose={onClose} />); |
| 59 | + fireEvent.keyDown(window, { key: "Escape" }); |
| 60 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 61 | + unmount(); |
| 62 | + fireEvent.keyDown(window, { key: "Escape" }); |
| 63 | + expect(onClose, "卸载后 Esc 仍触发 ⇒ window 监听没解绑").toHaveBeenCalledTimes(1); |
| 64 | + }); |
| 65 | + |
| 66 | + it("② 点遮罩关闭;面板内点击不关闭(缩进层次:遮罩与面板是两个节点)", () => { |
| 67 | + const onClose = vi.fn(); |
| 68 | + render(<CommandPalette {...base} onClose={onClose} />); |
| 69 | + const panel = screen.getByTestId("command-palette"); |
| 70 | + fireEvent.mouseDown(panel); // 面板内按下(含面板自身的空白区) |
| 71 | + expect(onClose, "面板内点击把面板关掉了").not.toHaveBeenCalled(); |
| 72 | + const overlay = screen.getByTestId("command-palette-overlay"); |
| 73 | + expect(overlay.contains(panel), "面板不在遮罩里 ⇒ 点遮罩判据测的不是本组件").toBe(true); |
| 74 | + fireEvent.mouseDown(overlay); |
| 75 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 76 | + }); |
| 77 | + |
| 78 | + it("③ ↑↓ 改选中项(aria-selected 单一真源),Enter 执行并关闭", () => { |
| 79 | + const onPick = vi.fn(); |
| 80 | + const onClose = vi.fn(); |
| 81 | + render(<CommandPalette {...base} onPick={onPick} onClose={onClose} />); |
| 82 | + const input = screen.getByTestId("command-palette-input"); |
| 83 | + // 首项默认选中(classroom = 注册表第 1 项) |
| 84 | + expect(screen.getByTestId(`command-page:${ALL_ENTRIES[0].key}`).getAttribute("aria-selected")).toBe("true"); |
| 85 | + fireEvent.keyDown(input, { key: "ArrowDown" }); |
| 86 | + expect(screen.getByTestId(`command-page:${ALL_ENTRIES[1].key}`).getAttribute("aria-selected")).toBe("true"); |
| 87 | + expect(screen.getByTestId(`command-page:${ALL_ENTRIES[0].key}`).getAttribute("aria-selected")).toBe("false"); |
| 88 | + fireEvent.keyDown(input, { key: "ArrowUp" }); |
| 89 | + expect(screen.getByTestId(`command-page:${ALL_ENTRIES[0].key}`).getAttribute("aria-selected")).toBe("true"); |
| 90 | + fireEvent.keyDown(input, { key: "Enter" }); |
| 91 | + expect(onPick).toHaveBeenCalledWith({ kind: "page", key: ALL_ENTRIES[0].key }); |
| 92 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 93 | + }); |
| 94 | + |
| 95 | + it("③ 输入过滤:无匹配时给空态;过滤后选中位回到首项(不指向已被滤掉的命令)", () => { |
| 96 | + render(<CommandPalette {...base} />); |
| 97 | + const input = screen.getByTestId("command-palette-input"); |
| 98 | + fireEvent.keyDown(input, { key: "ArrowDown" }); // 先把选中位挪到第 2 项 |
| 99 | + fireEvent.change(input, { target: { value: "面板" } }); // 只剩「对话面板」一条 |
| 100 | + expect(screen.queryByTestId(`command-page:${ALL_ENTRIES[0].key}`)).toBeNull(); |
| 101 | + expect(screen.getByTestId("command-dock").getAttribute("aria-selected")).toBe("true"); |
| 102 | + fireEvent.change(input, { target: { value: "zzz-不存在的命令" } }); |
| 103 | + expect(screen.getByTestId("command-palette-empty")).toBeTruthy(); |
| 104 | + }); |
| 105 | + |
| 106 | + it("③ 面板里的「对话面板」是增量入口(onPick kind=dock),顶栏 dock-toggle 不因此消失", () => { |
| 107 | + const onPick = vi.fn(); |
| 108 | + render(<CommandPalette {...base} onPick={onPick} />); |
| 109 | + fireEvent.change(screen.getByTestId("command-palette-input"), { target: { value: "面板" } }); |
| 110 | + fireEvent.click(screen.getByTestId("command-dock")); |
| 111 | + expect(onPick).toHaveBeenCalledWith({ kind: "dock" }); |
| 112 | + }); |
| 113 | + |
| 114 | + it("④ IME 组合中按 Enter 不提交(判据自带阳性对照:同一命令普通 Enter 必须提交)", () => { |
| 115 | + const onPick = vi.fn(); |
| 116 | + const onClose = vi.fn(); |
| 117 | + render(<CommandPalette {...base} onPick={onPick} onClose={onClose} />); |
| 118 | + const input = screen.getByTestId("command-palette-input"); |
| 119 | + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); |
| 120 | + expect(onPick, "组合中按 Enter 提交了(中文输入法会误触)").not.toHaveBeenCalled(); |
| 121 | + expect(onClose, "组合中按 Enter 把面板关了").not.toHaveBeenCalled(); |
| 122 | + // 阳性对照:去掉 isComposing 后同一条命令必须提交 —— 否则上一行只是「Enter 从来没生效」 |
| 123 | + fireEvent.keyDown(input, { key: "Enter" }); |
| 124 | + expect(onPick).toHaveBeenCalledWith({ kind: "page", key: ALL_ENTRIES[0].key }); |
| 125 | + expect(onClose).toHaveBeenCalledTimes(1); |
| 126 | + }); |
| 127 | + |
| 128 | + it("⑤ 命令列表含 9 个页面跳转命令,label 逐字取自注册表(不硬编码、含设置页)", () => { |
| 129 | + render(<CommandPalette {...base} />); |
| 130 | + expect(ALL_ENTRIES, "注册表条目数变了 ⇒ 本判据的锚点失效").toHaveLength(9); |
| 131 | + for (const e of ALL_ENTRIES) { |
| 132 | + const item = screen.getByTestId(`command-page:${e.key}`); |
| 133 | + expect(item.querySelector(".ed-cmdk__label")?.textContent, e.key).toBe(e.label); |
| 134 | + } |
| 135 | + expect(screen.queryByTestId("command-page:not-a-page")).toBeNull(); // 阴性样本 |
| 136 | + }); |
| 137 | + |
| 138 | + it("⑤ 静态纪律:不 import 原语层 · 层级走 zIndex() 标尺且无裸数字", () => { |
| 139 | + expect(/ui\/primitives/.test(TSX), "面板 import 了原语层(会把整层 CSS 拉进首屏)").toBe(false); |
| 140 | + expect(TSX.includes("<" + "svg"), "面板里出现内联 svg(图标一律走 ui/icons)").toBe(false); |
| 141 | + expect(/zIndex\(/.test(TSX), "层级没有走六档标尺").toBe(true); |
| 142 | + expect(/zIndex:\s*\d/.test(TSX), "层级写了裸数字").toBe(false); |
| 143 | + }); |
| 144 | + |
| 145 | + it("⑤ CSS 里没有动效声明(transition/animation/@keyframes 属批 6)", () => { |
| 146 | + const banned = [/transition\s*:/, /animation\s*:/, /@keyframes/]; |
| 147 | + // 仪器自检:同一组正则对一段**确定含动效**的 CSS 必须命中(否则下面的「0 命中」是假绿) |
| 148 | + expect(banned.some((re) => re.test(".x { transition: opacity 200ms; }"))).toBe(true); |
| 149 | + const hits = banned.filter((re) => re.test(CSS)).map(String); |
| 150 | + expect(hits, `批 3 的面板 CSS 不得含动效(属批 6):${hits.join(" / ")}`).toEqual([]); |
| 151 | + }); |
| 152 | +}); |
| 153 | + |
| 154 | +describe("App.tsx 的 ⌘K 接线(**静态**判据)", () => { |
| 155 | + /** |
| 156 | + * 仪器局限(如实声明,不许当成运行期证据):这四条是**源码文本**判据。 |
| 157 | + * `App` 整树依赖 Tauri IPC(`invoke` / `listen` / `getCurrentWindow`),仓内**没有任何测试 import 它** |
| 158 | + * (实测 `importing_App=[]`,136 个测试文件逐个扫)⇒ 「按一次 Ctrl+K 面板真的开了」只有真机 / WebView2 |
| 159 | + * 能给证据,本批未跑(§报告「没能验证的地方」)。这四条挡的是**接线被删/被改回去**这类静默回归。 |
| 160 | + */ |
| 161 | + it("顶栏按钮 · Ctrl+K 监听 · 面板挂载三处都在,且 onPick 收口经 isPageKey(反例自检:空实现必须不通过)", () => { |
| 162 | + expect(/onOpenPalette=\{\(\) => setPaletteOpen\(true\)\}/.test(APP_CODE), "⌘K 按钮没接到面板").toBe(true); |
| 163 | + expect(/<CommandPalette[\s\S]*?open=\{paletteOpen\}/.test(APP_CODE), "面板挂载丢了").toBe(true); |
| 164 | + expect(/onClose=\{\(\) => setPaletteOpen\(false\)\}/.test(APP_CODE), "面板的关闭回调丢了").toBe(true); |
| 165 | + expect(/isPageKey\(pick\.key\)/.test(APP_CODE), "onPick 不过 isPageKey 就 setPage").toBe(true); |
| 166 | + // Ctrl+K 必须显式排除 Shift/Alt —— 否则会与既有的 Ctrl+Shift+A(对话面板)抢同一个事件 |
| 167 | + expect(/e\.ctrlKey && !e\.shiftKey && !e\.altKey && \(e\.key === "k" \|\| e\.key === "K"\)/.test(APP_CODE)).toBe(true); |
| 168 | + // 反例自检:同一组判据对「T7 时期的空实现」必须不命中,否则这四条是恒真的 |
| 169 | + expect(/onOpenPalette=\{\(\) => setPaletteOpen\(true\)\}/.test("onOpenPalette={() => {}}")).toBe(false); |
| 170 | + expect(/isPageKey\(pick\.key\)/.test("setPage(pick.key)")).toBe(false); |
| 171 | + }); |
| 172 | +}); |
0 commit comments