22/**
33 * @ai -context ⌘K 命令面板的契约守卫(规格 §6.1;批 3 Task 11)。
44 *
5- * 判据分五层,每层都能**独立变红**(每条判据的变异体实测见 `task-11-report.md` §5):
6- * ① 契约:`role="dialog"` + `aria-modal` + 输入框自动聚焦(**与 `Modal` 对齐的五条契约之三**,
7- * 另两条 = ESC 关 / 点遮罩关,在 ② 层);
8- * ② 关闭路径:Esc 关(window 冒泡相)· 点遮罩关(`mousedown`,与 `Modal` 同款)· 面板内点击**不**关;
5+ * 判据分五层,每层都能**独立变红**(每条判据的变异体实测见 `task-11-report.md` §5;
6+ * **批 4 T9 的改写与各自的新变异体见 `task-9-report.md` §6.2**):
7+ * ① 契约:`role="dialog"` + `aria-modal` + 无障碍名整条链(`aria-labelledby` → 可见标题)+
8+ * 输入框自动聚焦(**与 `Modal` 对齐的五条契约之三**,另两条 = ESC 关 / 点遮罩关,在 ② 层);
9+ * ② 关闭路径:Esc 关(`Modal` 的 `document` 冒泡相 + 栈顶)· 点遮罩关(`mousedown`,与 `Modal` 同款)·
10+ * 面板内点击**不**关 · 遮罩的层级落在六档标尺上;
911 * ③ 键盘导航:↑↓ 改选中项(`aria-selected`)· Enter 执行并关闭 · 过滤后选中位回到首项;
1012 * ④ IME:**组合中按 Enter 不提交**(中文输入法不能误触)—— 判据自带**阳性对照**(同一条命令
1113 * 在不带 `isComposing` 的 Enter 下必须提交),否则「没提交」可能只是「Enter 从来没生效」;
12- * ⑤ 静态纪律:9 个页面跳转命令来自注册表(不硬编码 label)· 不 import 原语层 · 层级走 `zIndex()`
13- * 且无裸数字 · CSS 无 transition/animation(动效属批 6)。
14+ * ⑤ 静态纪律:9 个页面跳转命令来自注册表(不硬编码 label)· **只经 barrel 导入原语**(T9 起;
15+ * 批 3 的「不许 import 原语层」是非目标 2 的临时守卫,前提已被批 4 解除)· **不留第二套
16+ * 遮罩/ESC/焦点**(ADR-033 §7)· 层级无裸数字 · 自带 CSS 整份删除且无 `.ed-cmdk*` 残类。
17+ * ⚠️ 判据前**先剥注释**(`CODE`):文件头逐字点到 `createPortal` / `aria-modal` / `.ed-cmdk*`
18+ * 这些形态,注释不算犯规(与 `TopBar.test.tsx:34` 同一口径)。
1419 *
1520 * ⚠️ 本仓测试底座(照抄勿改):`vitest.config.ts` 全局 `environment: "node"` ⇒ 首行必须
1621 * `// @vitest-environment jsdom`;**未装** `jest-dom` 与 `user-event` ⇒ 断言用原生 DOM API、
1722 * 交互用 `fireEvent`;**没有** `globals` ⇒ RTL 的 auto-cleanup 不生效,必须显式 `afterEach(cleanup)`。
1823 */
19- import { readFileSync } from "node:fs" ;
24+ import { existsSync , readFileSync } from "node:fs" ;
2025import { dirname , join } from "node:path" ;
2126import { fileURLToPath } from "node:url" ;
2227import { cleanup , fireEvent , render , screen } from "@testing-library/react" ;
2328import { afterEach , describe , expect , it , vi } from "vitest" ;
2429import { CommandPalette } from "./CommandPalette" ;
2530import { ALL_ENTRIES } from "./navRegistry" ;
31+ import { zIndex } from "../ui/zIndex" ;
2632
2733const HERE = dirname ( fileURLToPath ( import . meta. url ) ) ;
2834const 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, "" ) ;
35+ /** 迁移面的**只留代码**版本(剥块注释 + 整行 `//`)—— 与 `TopBar.test.tsx:34` 同一口径:
36+ * 文件头解释「不许再自带 CSS / 不许留 `.ed-cmdk*` 残类」时会点到那些字面量,注释不算犯规。 */
37+ const CODE = TSX . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, "" ) . replace ( / ^ [ \t ] * \/ \/ .* $ / gm, "" ) ;
38+ /** T9:`CommandPalette.css` 已整份删除(计划 V1)⇒ 原「CSS 里没有动效声明」判据改建为
39+ * 「CSS 层只剩原语那一份」(同一 `it`,用例数不减;判据强度不降,见该用例的说明)。 */
3140/** `App.tsx` 的**只留代码**版本(剥块注释 + 整行 `//`)—— 与 `TopBar.test.tsx:43` 同一口径 */
3241const APP_CODE = readFileSync ( join ( HERE , ".." , "App.tsx" ) , "utf8" )
3342 . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, "" )
@@ -43,7 +52,11 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
4352 const dialog = screen . getByTestId ( "command-palette" ) ;
4453 expect ( dialog . getAttribute ( "role" ) ) . toBe ( "dialog" ) ;
4554 expect ( dialog . getAttribute ( "aria-modal" ) ) . toBe ( "true" ) ;
46- expect ( dialog . getAttribute ( "aria-label" ) ) . toBe ( "命令面板" ) ;
55+ // T9:无障碍名从 `aria-label` 搬到 `aria-labelledby` → 可见标题(`Modal` 契约)。**强度不降**:
56+ // 旧判据只验一个属性字面量;新判据验「属性 → 标题节点 → 文本」**整条链**(任一处断链即红)。
57+ const labelledBy = dialog . getAttribute ( "aria-labelledby" ) ;
58+ expect ( labelledBy , "面板没有无障碍名(aria-labelledby)" ) . toBeTruthy ( ) ;
59+ expect ( document . getElementById ( labelledBy ?? "" ) ?. textContent , "无障碍名不指向可见标题" ) . toBe ( "命令面板" ) ;
4760 // 焦点:面板打开即落在输入框(键盘用户不必先按 Tab 找入口)
4861 expect ( document . activeElement ) . toBe ( screen . getByTestId ( "command-palette-input" ) ) ;
4962 } ) ;
@@ -56,10 +69,10 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
5669 it ( "② Esc 关闭;关闭后 Esc 不再触发(监听随 open 解绑,避免常驻 window 监听)" , ( ) => {
5770 const onClose = vi . fn ( ) ;
5871 const { unmount } = render ( < CommandPalette { ...base } onClose = { onClose } /> ) ;
59- fireEvent . keyDown ( window , { key : "Escape" } ) ;
72+ fireEvent . keyDown ( document , { key : "Escape" } ) ; // T9:ESC 归 `Modal`(document 冒泡相 + 栈顶)
6073 expect ( onClose ) . toHaveBeenCalledTimes ( 1 ) ;
6174 unmount ( ) ;
62- fireEvent . keyDown ( window , { key : "Escape" } ) ;
75+ fireEvent . keyDown ( document , { key : "Escape" } ) ; // T9:ESC 归 `Modal`(document 冒泡相 + 栈顶)
6376 expect ( onClose , "卸载后 Esc 仍触发 ⇒ window 监听没解绑" ) . toHaveBeenCalledTimes ( 1 ) ;
6477 } ) ;
6578
@@ -73,6 +86,9 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
7386 expect ( overlay . contains ( panel ) , "面板不在遮罩里 ⇒ 点遮罩判据测的不是本组件" ) . toBe ( true ) ;
7487 fireEvent . mouseDown ( overlay ) ;
7588 expect ( onClose ) . toHaveBeenCalledTimes ( 1 ) ;
89+ // T9:层级判据从「源码里出现 `zIndex(`」搬到**渲染出的遮罩**(强于源码文本判据:它验的是
90+ // 「层级真的落在六档标尺上」,而不是「本文件写没写那五个字符」;判据对象随迁移搬到原语层)。
91+ expect ( overlay . style . zIndex , "遮罩的层级不在六档标尺上" ) . toBe ( String ( zIndex ( "modal" ) ) ) ;
7692 } ) ;
7793
7894 it ( "③ ↑↓ 改选中项(aria-selected 单一真源),Enter 执行并关闭" , ( ) => {
@@ -130,24 +146,40 @@ describe("CommandPalette 契约(规格 §6.1)", () => {
130146 expect ( ALL_ENTRIES , "注册表条目数变了 ⇒ 本判据的锚点失效" ) . toHaveLength ( 9 ) ;
131147 for ( const e of ALL_ENTRIES ) {
132148 const item = screen . getByTestId ( `command-page:${ e . key } ` ) ;
133- expect ( item . querySelector ( ".ed-cmdk__label" ) ?. textContent , e . key ) . toBe ( e . label ) ;
149+ // T9(选项 (b)):行内容改由 `Text` 原语表达、`.ed-cmdk*` 残类全部删除 ⇒ 判据从「类选择器」
150+ // 改成**结构 + 文本**:选项的**首个元素子节点**恰是标签文本(原判据只锁类名与文本,
151+ // 新判据连「标签在行首、hint 在其后」这层结构一起锁 ⇒ 强度不降)。
152+ expect ( item . firstElementChild ?. textContent , e . key ) . toBe ( e . label ) ;
134153 }
135154 expect ( screen . queryByTestId ( "command-page:not-a-page" ) ) . toBeNull ( ) ; // 阴性样本
136155 } ) ;
137156
138- it ( "⑤ 静态纪律:不 import 原语层 · 层级走 zIndex() 标尺且无裸数字" , ( ) => {
139- expect ( / u i \/ p r i m i t i v e s / . test ( TSX ) , "面板 import 了原语层(会把整层 CSS 拉进首屏)" ) . toBe ( false ) ;
157+ it ( "⑤ 静态纪律:只经 barrel 导入原语 · 不留第二套遮罩/ESC/焦点(ADR-033 §7)· 无裸层级" , ( ) => {
158+ // 批 3 的「不 import `ui/primitives`」是**非目标 2 的临时守卫**(那条禁令的前提已被批 4 解除:
159+ // 迁移原语正是本批目标)⇒ 它按新架构换成**更强**的形态:入口唯一(barrel)+ 单实现(不留第二套
160+ // 弹层机制)+ 自带 CSS 层清零。四条原断言里「无内联 svg」与「无裸数字」两条逐字保留。
161+ // ⚠️ 本文件不得出现那个标签的**字面量**(连注释也不行):`ui/icons/no-inline-svg.test.ts` 是
162+ // **全文扫描**的棘轮,注释里出现即被判为「新增内联 svg 的文件」(`EmptyState.tsx` 边界③ 同坑)。
163+ expect ( / f r o m \s + " \. \. \/ u i \/ p r i m i t i v e s " / . test ( CODE ) , "面板没走 barrel(ADR-033 §1)" ) . toBe ( true ) ;
164+ expect ( CODE . match ( / f r o m \s + " \. \. \/ u i \/ p r i m i t i v e s \/ [ A - Z a - z ] + " / ) , "面板深导入原语(深导入不带 motion.css)" ) . toBeNull ( ) ;
140165 expect ( TSX . includes ( "<" + "svg" ) , "面板里出现内联 svg(图标一律走 ui/icons)" ) . toBe ( false ) ;
141- expect ( / z I n d e x \( / . test ( TSX ) , "层级没有走六档标尺" ) . toBe ( true ) ;
142- expect ( / z I n d e x : \s * \d / . test ( TSX ) , "层级写了裸数字" ) . toBe ( false ) ;
166+ expect ( / z I n d e x : \s * \d / . test ( CODE ) , "层级写了裸数字" ) . toBe ( false ) ;
167+ // 单实现(ADR-033 §7):portal / 事件监听 / 弹层语义 / 遮罩 / 焦点陷阱都只有 `Modal` 一份
168+ expect ( CODE . match ( / c r e a t e P o r t a l / ) , "面板自建 portal" ) . toBeNull ( ) ;
169+ expect ( CODE . match ( / a d d E v e n t L i s t e n e r / ) , "面板自建事件监听(ESC 与焦点都归 `Modal`)" ) . toBeNull ( ) ;
170+ expect ( CODE . match ( / a r i a - m o d a l / ) , "面板自建 `aria-modal`(第二套弹层语义)" ) . toBeNull ( ) ;
171+ expect ( CODE . match ( / p o s i t i o n \s * : \s * [ " ' ] f i x e d [ " ' ] / ) , "面板自建遮罩(position: fixed)" ) . toBeNull ( ) ;
172+ expect ( CODE . match ( / i n s e t \s * : \s * 0 / ) , "面板自建遮罩(inset: 0)" ) . toBeNull ( ) ;
173+ expect ( CODE . match ( / u s e F o c u s T r a p | F O C U S A B L E _ S E L E C T O R / ) , "面板自建焦点陷阱" ) . toBeNull ( ) ;
143174 } ) ;
144175
145- it ( "⑤ CSS 里没有动效声明(transition/animation/@keyframes 属批 6)" , ( ) => {
146- const banned = [ / t r a n s i t i o n \s * : / , / a n i m a t i o n \s * : / , / @ k e y f r a m e s / ] ;
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 ( [ ] ) ;
176+ it ( "⑤ 自带 CSS 整份删除(计划 V1):文件不在盘上,迁移面也不 import 任何 CSS / 不留残类" , ( ) => {
177+ // 判据对象随迁移**搬走**(原判据扫的是那个文件的文本;文件按 V1 删除 ⇒ 判据升级为「它必须不在」)。
178+ expect ( existsSync ( join ( HERE , "CommandPalette.css" ) ) , "CommandPalette.css 又回来了" ) . toBe ( false ) ;
179+ expect ( CODE . match ( / \b i m p o r t \s + [ " ' ] [ ^ " ' ] + \. c s s [ " ' ] / ) , "迁移面又 import 了 CSS" ) . toBeNull ( ) ;
180+ expect ( CODE . match ( / e d - c m d k / ) , "`.ed-cmdk*` 残类(样式已无落点)" ) . toBeNull ( ) ;
181+ // 仪器自证:同一条「存在性」读法对**确定存在**的文件必须为 true(否则上面那条 false 可能只是路径写错)
182+ expect ( existsSync ( join ( HERE , "CommandPalette.tsx" ) ) , "文件存在性读法失效" ) . toBe ( true ) ;
151183 } ) ;
152184} ) ;
153185
0 commit comments