|
| 1 | +/** |
| 2 | + * @ai-context responseCoverage.test.ts — **响应层七类动作回执的覆盖守卫**(批 6 T12;node 环境,无 DOM)。 |
| 3 | + * |
| 4 | + * Why:规格 §8.6.1 第 2 条逐字列七类动作(点击 / 拖拽 / 悬停 / 键入 / 勾选 / 展开折叠 / 切换视图), |
| 5 | + * 每类都必须有即时回执,且逐字「**这是验收口径,不是形容词**:批 6 收口时逐类动作列出其响应层 |
| 6 | + * 动效,缺一即不达标」。T12 的兑现方式 = **元素级全局回执**(`motion.css` 的响应层节,零调用点 |
| 7 | + * 改动)+ 原语层(`Button.css` 的焦点环落纸 / `ViewSwitcher.css` 的段控件),本文件把「每一类在 |
| 8 | + * **今天的调用面**上真能拿到回执」写成机器判据: |
| 9 | + * ① 逐类:**真实调用点形态**(实测下界)+ **落点**(元素选择器 ∨ 原语层选择器)两侧都要在; |
| 10 | + * ② 声明集合审计(安全网):响应层不得声明任何会改变交互语义或布局的属性; |
| 11 | + * ③ 未覆盖余量**逐条登记**且锚到真实文件(登记表不许说谎:每条都要在盘上仍然存在); |
| 12 | + * ④ reduced-motion **双向包含**(落点全在名单里 · 名单里没有死条目); |
| 13 | + * ⑤ 三档同行为(§8.5 节能档逐字「只留响应层」)+ 桶边界(无 `@media` / `@keyframes` / `animation`) |
| 14 | + * + 单一真源(零 `--ed-*` 定义)+ token 时长/缓动(兜底 == 生成器真源); |
| 15 | + * ⑥ 控制方 2026-09-13 追加的两条(T6 评审 I-2 / I-3):reduced 块的**每条声明都带 `!important`** |
| 16 | + * (「系统优先于档位」的**真实承重机理**)与「档位块与 reduced 块之间零规则块」; |
| 17 | + * ⑦ 原语层的焦点环"落纸"(`Button.css`:基准态透明 outline 给插值起点)。 |
| 18 | + * |
| 19 | + * 副作用:只读磁盘(`app/src` 全树 + `primitives/*.css` + `gen-tokens.mjs` 的导出),不修改任何文件。 |
| 20 | + * 边界:① 口径 = 剥注释后的文本(本仓 3 起「注释字面量骗过整文件扫描器」的先例); |
| 21 | + * ② 调用点计数一律**下界**(并行任务新增文件不该让判据变红),余量表用**精确长度 + 锚**; |
| 22 | + * ③ jsdom 侧的可达性与交互语义判据在 `responseReceipt.dom.test.tsx`(本文件无 DOM); |
| 23 | + * ④ 🔴 本文件不得出现 `var(--ed-dur-` / `var(--ed-ease` 的**裸串**(`motionTokens.consumption.test.ts` |
| 24 | + * 会把它们当成缺兜底的消费点 ⇒ 假红)⇒ 一律用转义正则。 |
| 25 | + */ |
| 26 | +import { existsSync, readFileSync } from "node:fs"; |
| 27 | +import { join } from "node:path"; |
| 28 | +import { describe, expect, it } from "vitest"; |
| 29 | +import { |
| 30 | + SRC, baseSelector, offendersIn, productionFiles, readPrimitiveCss, reducedMotionSelectors, |
| 31 | + responseRules, responseTransitionSelectors, stripComments, |
| 32 | +} from "./responseScan"; |
| 33 | + |
| 34 | +const RULES = responseRules(); |
| 35 | +const SELECTORS: readonly string[] = [...new Set(RULES.map((r) => r.selector))]; |
| 36 | +/** 落点的**基选择器**(剥掉状态伪类):判"这一类动作的落点在不在"用它,判"名单是否逐字完整"用原名 */ |
| 37 | +const BASE_SELECTORS: readonly string[] = [...new Set(RULES.map((r) => baseSelector(r.selector)))]; |
| 38 | +const hasLanding = (shape: string): boolean => BASE_SELECTORS.some((s) => s.includes(shape)); |
| 39 | +const TRANSITION_SELECTORS = responseTransitionSelectors(); |
| 40 | +const REDUCED_SELECTORS = reducedMotionSelectors(); |
| 41 | + |
| 42 | +/** 审计器的反例样本:只声明这些 = 顺手改了可交互性 / 布局(响应层绝不允许) */ |
| 43 | +const FORBIDDEN_SAMPLE = "display: none; width: 10px; pointer-events: none;"; |
| 44 | + |
| 45 | +/** 响应层的**元素形态**(每一类动作落到的选择器形态;`null` = 该类由原语层承担) */ |
| 46 | +interface ActionClass { |
| 47 | + readonly name: string; |
| 48 | + /** 该类在响应层里的落点(至少一条命中才算有回执) */ |
| 49 | + readonly elements: readonly string[]; |
| 50 | + /** 该类在**原语层**的承载体(样式表 + 选择器);`null` = 今天没有原语 */ |
| 51 | + readonly primitive: { readonly file: string; readonly selector: string } | null; |
| 52 | + /** 今天的调用面形态(正则 + 下界):下界是"这一类真有调用点"的见证 */ |
| 53 | + readonly callSites: { readonly re: RegExp; readonly min: number; readonly what: string }; |
| 54 | +} |
| 55 | +const ACTION_CLASSES: readonly ActionClass[] = [ |
| 56 | + { |
| 57 | + name: "点击", elements: ["button:not(.ed-btn)", '[role="button"]'], |
| 58 | + primitive: { file: "Button.css", selector: ".ed-btn" }, |
| 59 | + callSites: { re: /<button[\s>]/g, min: 400, what: "裸 `<button>`" }, |
| 60 | + }, |
| 61 | + { |
| 62 | + name: "拖拽", elements: ['[draggable="true"]', '[role="separator"]'], primitive: null, |
| 63 | + callSites: { re: /onPointerDown=|onDragStart=|setPointerCapture/g, min: 3, what: "拖拽起手" }, |
| 64 | + }, |
| 65 | + { |
| 66 | + name: "悬停", elements: ["button:not(.ed-btn)", "details > summary", '[role="separator"]'], |
| 67 | + primitive: { file: "Surface.css", selector: ".ed-surface--interactive:hover" }, |
| 68 | + callSites: { re: /onPointerEnter=|onMouseEnter=|onMouseLeave=/g, min: 7, what: "JS 悬停" }, |
| 69 | + }, |
| 70 | + { |
| 71 | + name: "键入", elements: ['input:not([type="checkbox"]):not([type="radio"])', "textarea"], primitive: null, |
| 72 | + callSites: { re: /<input[\s>]|<textarea[\s>]/g, min: 110, what: "输入元素" }, |
| 73 | + }, |
| 74 | + { |
| 75 | + name: "勾选", elements: ['input[type="checkbox"]'], primitive: null, |
| 76 | + callSites: { re: /type="checkbox"/g, min: 25, what: "原生 checkbox" }, |
| 77 | + }, |
| 78 | + { |
| 79 | + name: "展开折叠", elements: ["details > summary"], primitive: null, |
| 80 | + callSites: { re: /setManualFolded|setFolded|onToggleFold|setExpanded/g, min: 25, what: "折叠动作" }, |
| 81 | + }, |
| 82 | + { |
| 83 | + name: "切换视图", elements: [], primitive: { file: "ViewSwitcher.css", selector: ".ed-btn--segment" }, |
| 84 | + callSites: { re: /<ViewSwitcher|onViewKeyChange|setTab\(|setMiddleView\(/g, min: 12, what: "切换入口" }, |
| 85 | + }, |
| 86 | +]; |
| 87 | + |
| 88 | +const PROD = productionFiles(); |
| 89 | +const hitsOf = (re: RegExp): number => PROD.reduce((n, f) => n + [...f.text.matchAll(re)].length, 0); |
| 90 | + |
| 91 | +/** 未覆盖余量:每条都必须**锚到盘上仍然存在**的调用点(登记表不许说谎) */ |
| 92 | +interface Remainder { |
| 93 | + readonly key: string; |
| 94 | + readonly why: string; |
| 95 | + readonly file: string; |
| 96 | + readonly anchor: RegExp; |
| 97 | +} |
| 98 | +const UNCOVERED: readonly Remainder[] = [ |
| 99 | + { |
| 100 | + key: "点击 · 裸 div+onClick 且无 role(395 处里的这一族)", |
| 101 | + why: "div 不是按钮语义;补 role 会改 DOM 契约(撞棘轮)⇒ 属批 7/8 的无障碍收口", |
| 102 | + file: "components/ColumnBar.tsx", anchor: /onClick=\{onClick\}/, |
| 103 | + }, |
| 104 | + { |
| 105 | + key: "拖拽 · ColumnResizer 的行内 transition 遮蔽类级过渡", |
| 106 | + why: "行内 `transition` shorthand 整条替换类上的 transition ⇒ 元素级只给状态回执;它自带的 0.15s 背景半场保留", |
| 107 | + file: "components/ColumnResizer.tsx", anchor: /transition: "background 0\.15s"/, |
| 108 | + }, |
| 109 | + { |
| 110 | + key: "拖拽 · NoteTreeSection 的 pointerdown 起手(无 role / 无 draggable)", |
| 111 | + why: "该行没有可元素级命中的形态标记 ⇒ 只能靠调用点补标记(批 7/8)", |
| 112 | + file: "components/NoteTreeSection.tsx", anchor: /onPointerDown=/, |
| 113 | + }, |
| 114 | + { |
| 115 | + key: "悬停 · 自建 tabbar / 画布浮钮的行高亮(8 处里的 JS 状态族)", |
| 116 | + why: "hover 靠 JS 改行内底色,元素选择器打不到(无 role / 无类)", |
| 117 | + file: "shell/CommandPalette.tsx", anchor: /onMouseEnter=/, |
| 118 | + }, |
| 119 | + { |
| 120 | + key: "切换视图 · 自建 tabbar 按钮(未走 ViewSwitcher 原语)", |
| 121 | + why: "自建 tabbar 是裸 `<button>` ⇒ 只拿到①的按钮回执,没有段控件的选中态回执", |
| 122 | + file: "pages/KnowledgePage.tsx", anchor: /setMiddleView\(/, |
| 123 | + }, |
| 124 | + { |
| 125 | + key: "勾选 · 无自定义勾选原语(26 处全是原生 checkbox)", |
| 126 | + why: "「勾选框落笔」的完整形态(笔画生长)需要自定义原语 ⇒ 本任务只能给元素级 accent-color 落墨", |
| 127 | + file: "components/GroupDeleteConfirm.tsx", anchor: /type="checkbox"/, |
| 128 | + }, |
| 129 | + { |
| 130 | + key: "键入 · 行内 border 遮蔽 token 色那一半", |
| 131 | + why: "调用点行内写了 border 色(如 #0d9488)⇒ 类级的 border-color 过渡打不到;焦点环那一半仍然生效", |
| 132 | + file: "components/session-detail/SessionDetailHeader.tsx", anchor: /border: "1px solid #0d9488"/, |
| 133 | + }, |
| 134 | +]; |
| 135 | + |
| 136 | +/** 元素级覆盖的形态数(勾选/单选 · 文本输入 · 按钮或 role=button · summary · draggable · separator) */ |
| 137 | +const ELEMENT_SHAPES: readonly string[] = [ |
| 138 | + 'input[type="checkbox"]', 'input[type="radio"]', |
| 139 | + 'input:not([type="checkbox"]):not([type="radio"])', "textarea", |
| 140 | + "button:not(.ed-btn)", '[role="button"]', "details > summary", |
| 141 | + '[draggable="true"]', '[role="separator"]', |
| 142 | +]; |
| 143 | + |
| 144 | +describe("① 七类动作逐类覆盖:今天的调用面形态 + 回执落点,缺一即不达标(§8.6.1 第 2 条)", () => { |
| 145 | + it("守卫自身的防空真闸:响应层节非空、七类表恰 7 行、下界全为正", () => { |
| 146 | + expect(RULES.length, "响应层节解析出 0 条规则 ⇒ 标记被改名或节被删空").toBeGreaterThanOrEqual(8); |
| 147 | + expect(ACTION_CLASSES, "七类动作表不得缩水").toHaveLength(7); |
| 148 | + expect(ACTION_CLASSES.every((c) => c.callSites.min > 0)).toBe(true); |
| 149 | + expect(UNCOVERED.length, "余量表被清空 = ③ 退化成空真").toBeGreaterThan(0); |
| 150 | + }); |
| 151 | + |
| 152 | + for (const cls of ACTION_CLASSES) { |
| 153 | + it(`${cls.name}:调用面真有「${cls.callSites.what}」(≥${cls.callSites.min})且落点在位`, () => { |
| 154 | + const n = hitsOf(cls.callSites.re); |
| 155 | + expect(n, `${cls.name} 的调用面实测 ${n} < 下界 ${cls.callSites.min} ⇒ 整类判据会变成空真`).toBeGreaterThanOrEqual(cls.callSites.min); |
| 156 | + if (cls.elements.length > 0) { |
| 157 | + for (const sel of cls.elements) { |
| 158 | + expect(hasLanding(sel), `${cls.name} 的落点 ${sel} 不在响应层节里(基选择器口径)`).toBe(true); |
| 159 | + } |
| 160 | + return; |
| 161 | + } |
| 162 | + expect(cls.primitive, `${cls.name} 既没有元素级落点、也没有原语承载`).not.toBeNull(); |
| 163 | + const { file, selector } = cls.primitive as { file: string; selector: string }; |
| 164 | + expect(readPrimitiveCss(file), `${cls.name} 的原语落点 ${selector} 在 ${file} 里没有规则`).toContain(selector); |
| 165 | + }); |
| 166 | + } |
| 167 | +}); |
| 168 | + |
| 169 | +describe("② 安全网:响应层的声明集合审计(动效不得改变交互语义或布局)", () => { |
| 170 | + it("每个声明都在白名单里(`pointer-events` / `display` / `width` … 一律不许出现)", () => { |
| 171 | + const offenders = RULES.flatMap((r) => |
| 172 | + offendersIn(r.body).map((p) => `${r.selector} { ${p} }`), |
| 173 | + ); |
| 174 | + expect(offenders, `响应层声明了白名单外的属性:\n${offenders.join("\n")}`).toEqual([]); |
| 175 | + }); |
| 176 | + |
| 177 | + it("反例对照:审计器对已知违规样本必须报满(否则上面那条是恒真)", () => { |
| 178 | + expect(offendersIn(FORBIDDEN_SAMPLE)).toEqual(["display", "width", "pointer-events"]); |
| 179 | + expect(offendersIn("transform: translateY(1px); filter: brightness(0.97);")).toEqual([]); |
| 180 | + }); |
| 181 | + |
| 182 | + it("覆盖形态数 = 9 条选择器 / 6 个元素族(少一条形态就是某一类掉了回执)", () => { |
| 183 | + expect(ELEMENT_SHAPES, "形态表不得缩水").toHaveLength(9); |
| 184 | + for (const sel of ELEMENT_SHAPES) expect(hasLanding(sel), `形态 ${sel} 不在响应层节里`).toBe(true); |
| 185 | + // 阳性对照:形态表用的基选择器口径必须真的剥掉了状态(否则"落点在位"会退化成逐字比对) |
| 186 | + expect(baseSelector('details[open] > summary')).toBe("details > summary"); |
| 187 | + expect(baseSelector('button:not(.ed-btn):not(:disabled):hover')).toBe("button:not(.ed-btn)"); |
| 188 | + }); |
| 189 | +}); |
| 190 | + |
| 191 | +describe("③ 未覆盖余量逐条登记(防「把余量写成 0」;登记表必须锚到真实调用点)", () => { |
| 192 | + it(`余量恰 ${UNCOVERED.length} 条,且每条锚点在盘上仍然存在`, () => { |
| 193 | + expect(UNCOVERED.map((r) => r.key)).toHaveLength(UNCOVERED.length); |
| 194 | + expect(UNCOVERED.length, "余量条数变了 ⇒ 先核实再改这个数(不许写 0)").toBe(7); |
| 195 | + for (const r of UNCOVERED) { |
| 196 | + const abs = join(SRC, ...r.file.split("/")); |
| 197 | + expect(existsSync(abs), `余量登记指向不存在的文件:${r.file}`).toBe(true); |
| 198 | + expect(stripComments(readFileSync(abs, "utf8")), `${r.file} 里的锚点已消失(余量被覆盖了?改登记)`).toMatch(r.anchor); |
| 199 | + expect(r.why.length, `${r.key} 缺非空理由`).toBeGreaterThan(12); |
| 200 | + } |
| 201 | + }); |
| 202 | + |
| 203 | + it("原语层的差集也登记到位:勾选/键入/折叠三类今天**没有**原语承载(不许声称有)", () => { |
| 204 | + const noPrimitive = ACTION_CLASSES.filter((c) => c.elements.length > 0 && c.primitive === null).map((c) => c.name); |
| 205 | + expect(noPrimitive, "这三类今天没有自定义原语 ⇒ 落点只能是元素级(如实登记)").toEqual(["拖拽", "键入", "勾选", "展开折叠"]); |
| 206 | + }); |
| 207 | +}); |
| 208 | + |
| 209 | +describe("④ reduced-motion 双向包含(落点全在名单里 · 名单里没有死条目)", () => { |
| 210 | + it("每个声明了 transition 的落点选择器都逐字在名单里(漏一个 = 那一处在 reduced-motion 下照旧动)", () => { |
| 211 | + expect(TRANSITION_SELECTORS.length, "响应层一处 transition 落点都没有 ⇒ 本节是空真").toBeGreaterThanOrEqual(6); |
| 212 | + const missing = TRANSITION_SELECTORS.filter((s) => !REDUCED_SELECTORS.includes(s)); |
| 213 | + expect(missing, `这些落点不在 motion.css 的 reduced-motion 名单里:\n${missing.join("\n")}`).toEqual([]); |
| 214 | + }); |
| 215 | + |
| 216 | + it("反向:名单里每个非 `.ed-*` 条目都对应响应层的一处落点(防「删了规则留死名单」)", () => { |
| 217 | + const elementEntries = REDUCED_SELECTORS.filter((s) => !s.startsWith(".")); |
| 218 | + const dead = elementEntries.filter((s) => !hasLanding(s)); |
| 219 | + expect(dead, `名单里的元素选择器在响应层节里没有对应落点:\n${dead.join("\n")}`).toEqual([]); |
| 220 | + // 阳性对照:`[role="separator"]` 只有状态规则(`[role="separator"]:hover`)也必须算落点在位 |
| 221 | + expect(elementEntries).toContain('[role="separator"]'); |
| 222 | + expect(elementEntries).toContain("textarea"); |
| 223 | + expect(REDUCED_SELECTORS).toContain(".ed-btn"); |
| 224 | + expect(SELECTORS.length, "响应层选择器表不得为空").toBeGreaterThanOrEqual(8); |
| 225 | + }); |
| 226 | +}); |
0 commit comments