Skip to content

Commit e531a0c

Browse files
committed
feat(motion): 落元素级响应层回执与覆盖守卫
1 parent 2071ec7 commit e531a0c

5 files changed

Lines changed: 969 additions & 8 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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

Comments
 (0)