Skip to content

Commit 3978e2b

Browse files
committed
feat(settings): 加动效强度三段控件
1 parent d2eacd2 commit 3978e2b

3 files changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context MotionIntensityControl.test.tsx —— 「动效强度」三段控件的行为级契约(批 6 Task 7),
4+
* 并承担 **R16.3② 的连带硬要求**:T6 的 `intensity.test.ts` 是 **node 环境**文件(不写 jsdom 头)
5+
* ⇒ 「hook 挂载即写 `<html data-motion>`」这一事实在 node 环境**不可判**(那里只能拿假元素判 DOM
6+
* 写入)⇒ 由本文件补 jsdom 判据:① 挂载即写 ② 切换即改(属性 + 持久化双写)③ 卸载/复挂载读回。
7+
*
8+
* Why 每条判据都在这里:
9+
* V1 选中态**恰一段**且等于当前档(受控值不是「看着像」,是可数出来的);
10+
* V2 点击 ⇒ `<html data-motion>` **与** `motion:intensity` **双写**(只看 DOM 会被「只 setState
11+
* 不落地」骗过;只看存储会被「只写属性不记忆」骗过 —— 两半各配一个专属变异体);
12+
* V3 段清单与真源同源:段数 / 文案 / 顺序三项对拍(硬字面量对照,**不从真源派生期望值**,否则
13+
* 「真源多一档」这类变异不会被发现 —— 与 T6 V7 的 CSS 侧值域判据双向互补);
14+
* V3b 每段点击都落到**自己**那个档位(一一对应,防「三段都写同一个值」);
15+
* V4 **零行内 style**(ADR-033 §4):扫**整棵子树**而不只根节点(防「包一层带 style 的壳」);
16+
* ⑤ **状态可观测**(控制方 2026-09-13 追加,来自 T6 评审 I-1):T6 的 `intensity.test.ts` 是 node
17+
* 环境 + SSR 探针(`renderToString` **返回后**才调 `select` ⇒ 不重渲染 ⇒ 探针永远读到切换**前**
18+
* 的值,删掉 `setValue(next)` 12 条全绿)⇒ 本文件用 jsdom 的 `renderHook` **切换后再读一次**,
19+
* 断言读到的是**新档**;同一次切换的另两半(`<html data-motion>` / `motion:intensity`)各自单列,
20+
* 三半互不代偿(专属变异体 m2a / m2b / m7)。
21+
*
22+
* 副作用:只挂 React 树 + 只写 jsdom 的 `<html data-motion>` / `localStorage` 桩 + 局部
23+
* `vi.stubGlobal("matchMedia")`(**不动全局 `src/test/setup.ts`**,先例 `usePresence.test.tsx`
24+
* `:54-93` 与 `Toast.test.tsx:58-66`)。
25+
* 边界(诚实登记):本仓**未装 `jest-dom` / `user-event`** ⇒ 断言一律**原生 DOM API**、交互一律
26+
* `fireEvent`;jsdom **不是真浏览器** ⇒ 「三档动画看起来不一样」不可机器判据(同 T6 诚实边界①)。
27+
*/
28+
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
29+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
30+
import { MotionIntensityControl } from "./MotionIntensityControl";
31+
import { MOTION_INTENSITIES, MOTION_INTENSITY_KEY, useMotionIntensity } from "../motion/intensity";
32+
33+
/** §8.5 三档:段名 ↔ 档位取值。**硬字面量**(判据的期望值不许从被测真源派生) */
34+
const TIERS = [
35+
{ key: "eco", label: "节能" },
36+
{ key: "standard", label: "标准" },
37+
{ key: "rich", label: "丰富" },
38+
] as const;
39+
/** T6 `intensity.ts:19` 的唯一入参(逐字) */
40+
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
41+
42+
type MqlListener = (event: MediaQueryListEvent) => void;
43+
interface MatchMediaStub {
44+
readonly impl: (query: string) => MediaQueryList;
45+
readonly queries: readonly string[];
46+
readonly legacyAdded: readonly MqlListener[];
47+
readonly legacyRemoved: readonly MqlListener[];
48+
}
49+
50+
/**
51+
* 造一个最小 `matchMedia` 桩(jsdom 30.0.1 **没有** `window.matchMedia` —— 本文件 ④ 自证)。
52+
* 🔴 `addListener` / `removeListener` **必须**实现:GSAP 与部分库走 **legacy 分支**,只实现
53+
* `addEventListener` 时它们**永远不会被调用**(R3.2 的连带口径)。
54+
*/
55+
function stubMatchMedia(matches: boolean): MatchMediaStub {
56+
const queries: string[] = [];
57+
const legacyAdded: MqlListener[] = [];
58+
const legacyRemoved: MqlListener[] = [];
59+
const mql = {
60+
matches,
61+
media: REDUCED_QUERY,
62+
onchange: null,
63+
addEventListener: (_type: string, _listener: MqlListener): void => undefined,
64+
removeEventListener: (_type: string, _listener: MqlListener): void => undefined,
65+
addListener: (listener: MqlListener | null): void => {
66+
if (listener !== null) legacyAdded.push(listener);
67+
},
68+
removeListener: (listener: MqlListener | null): void => {
69+
if (listener !== null) legacyRemoved.push(listener);
70+
},
71+
dispatchEvent: (): boolean => true,
72+
} as unknown as MediaQueryList; // 先例:`usePresence.test.tsx:83`(jsdom 无该 API,只能造)
73+
return {
74+
impl: (query: string): MediaQueryList => {
75+
queries.push(query);
76+
return mql;
77+
},
78+
queries,
79+
legacyAdded,
80+
legacyRemoved,
81+
};
82+
}
83+
84+
/** `<html>` 上的档位属性(判据一律读真 DOM,不读组件内部状态) */
85+
const htmlMotion = (): string | null => document.documentElement.getAttribute("data-motion");
86+
/** 三段(段类来自 `ViewSwitcher`:`ed-btn ed-btn--segment`) */
87+
const segs = (): HTMLButtonElement[] => [...document.querySelectorAll<HTMLButtonElement>(".ed-btn--segment")];
88+
const segByLabel = (label: string): HTMLButtonElement => {
89+
const seg = segs().find((s) => s.textContent === label);
90+
if (seg === undefined) throw new Error(`找不到文案为「${label}」的段`);
91+
return seg;
92+
};
93+
94+
beforeEach(() => {
95+
window.localStorage.clear();
96+
document.documentElement.removeAttribute("data-motion");
97+
});
98+
afterEach(() => {
99+
cleanup();
100+
vi.unstubAllGlobals();
101+
window.localStorage.clear();
102+
document.documentElement.removeAttribute("data-motion");
103+
});
104+
105+
describe("R16.3② ①挂载即写(T6 在 node 环境判不到的那一条)", () => {
106+
it("挂载前 `<html>` 无 `data-motion`(先证起点干净,防残留属性让断言空真);挂载后 = standard", () => {
107+
expect(htmlMotion()).toBeNull();
108+
render(<MotionIntensityControl />);
109+
expect(htmlMotion()).toBe("standard");
110+
});
111+
112+
it("`prefers-reduced-motion: reduce` ⇒ 初值 eco,且查询串逐字透传给 matchMedia", () => {
113+
const stub = stubMatchMedia(true);
114+
vi.stubGlobal("matchMedia", stub.impl);
115+
render(<MotionIntensityControl />);
116+
expect(htmlMotion()).toBe("eco");
117+
expect([...stub.queries]).toEqual([REDUCED_QUERY]);
118+
});
119+
});
120+
121+
describe("R16.3② ④桩自证:本环境没有 `matchMedia`,且桩实现了 legacy 的 addListener/removeListener", () => {
122+
it("`typeof window.matchMedia === \"undefined\"`(桩不是奢侈品)", () => {
123+
expect(typeof window.matchMedia).toBe("undefined");
124+
});
125+
126+
it("桩的 addListener/removeListener 真被调用得到(只实现 addEventListener 的桩会漏掉 legacy 分支)", () => {
127+
const stub = stubMatchMedia(false);
128+
const mql = stub.impl(REDUCED_QUERY);
129+
const listener: MqlListener = () => undefined;
130+
expect(typeof mql.addListener).toBe("function");
131+
expect(typeof mql.removeListener).toBe("function");
132+
mql.addListener(listener);
133+
mql.removeListener(listener);
134+
expect([...stub.legacyAdded]).toEqual([listener]);
135+
expect([...stub.legacyRemoved]).toEqual([listener]);
136+
});
137+
});
138+
139+
describe("V1 当前档反映在 DOM:`aria-pressed=\"true\"` 恰一段且等于当前档", () => {
140+
it("初值 ⇒ 恰「标准」为 true;点「丰富」后 true 段随之搬走", () => {
141+
render(<MotionIntensityControl />);
142+
expect(segs().length).toBe(TIERS.length);
143+
const pressed = (): string[] =>
144+
segs().filter((s) => s.getAttribute("aria-pressed") === "true").map((s) => s.textContent ?? "");
145+
expect(pressed()).toEqual(["标准"]);
146+
fireEvent.click(segByLabel("丰富"));
147+
expect(pressed()).toEqual(["丰富"]);
148+
});
149+
});
150+
151+
describe("R16.3② ②切换即改 + V2 双写:`<html data-motion>` 与 `motion:intensity` 同时落地", () => {
152+
it("点「丰富」⇒ `data-motion=\"rich\"` 且持久化键 == rich", () => {
153+
render(<MotionIntensityControl />);
154+
fireEvent.click(segByLabel("丰富"));
155+
expect(htmlMotion()).toBe("rich");
156+
expect(window.localStorage.getItem(MOTION_INTENSITY_KEY)).toBe("rich");
157+
});
158+
});
159+
160+
describe("R16.3② ⑤状态可观测(T6 评审 I-1 的盲区:node 探针只在渲染期取值)", () => {
161+
it("`renderHook`:切到 rich 后**再读一次** ⇒ 读到的是新档(删掉 `setValue(next)` 即红)", () => {
162+
const { result } = renderHook(() => useMotionIntensity());
163+
expect(result.current[0]).toBe("standard");
164+
act(() => {
165+
result.current[1]("rich");
166+
});
167+
expect(result.current[0], "读到的仍是切换**前**的值 ⇒ state 没被写").toBe("rich");
168+
});
169+
170+
it("组件侧同一事实:点「丰富」后**重新渲染**所得的值 = 新档(`aria-pressed` 由 `value` 算出)", () => {
171+
render(<MotionIntensityControl />);
172+
fireEvent.click(segByLabel("丰富"));
173+
expect(segs().filter((s) => s.getAttribute("aria-pressed") === "true").map((s) => s.textContent)).toEqual(["丰富"]);
174+
});
175+
});
176+
177+
describe("R16.3② ③卸载 / 复挂载:读回上次档位(持久化真的生效)", () => {
178+
it("选「丰富」→ 卸载 → 抹掉 `<html>` 上的属性 → 复挂载 ⇒ 又是 rich 且段仍选中", () => {
179+
const first = render(<MotionIntensityControl />);
180+
fireEvent.click(segByLabel("丰富"));
181+
expect(htmlMotion()).toBe("rich");
182+
first.unmount();
183+
document.documentElement.removeAttribute("data-motion"); // 防「读到上一次的残留」
184+
expect(htmlMotion()).toBeNull();
185+
render(<MotionIntensityControl />);
186+
expect(htmlMotion()).toBe("rich");
187+
expect(segByLabel("丰富").getAttribute("aria-pressed")).toBe("true");
188+
});
189+
});
190+
191+
describe("V3 三段与真源同源(段数 / 文案 / 顺序三项对拍)", () => {
192+
it("段数 == 3(§8.5)、文案逐字、顺序与 `MOTION_INTENSITIES` 一致", () => {
193+
render(<MotionIntensityControl />);
194+
expect(segs().length).toBe(3);
195+
expect(segs().map((s) => s.textContent)).toEqual(TIERS.map((t) => t.label));
196+
expect([...MOTION_INTENSITIES]).toEqual(TIERS.map((t) => t.key));
197+
});
198+
});
199+
200+
describe("V3b 每段都落到自己那个档位(一一对应,防「三段写同一个值」)", () => {
201+
it("逐段点击 ⇒ `data-motion` 依次 = eco / standard / rich(属性与记忆同时跟手)", () => {
202+
render(<MotionIntensityControl />);
203+
for (const tier of TIERS) {
204+
fireEvent.click(segByLabel(tier.label));
205+
expect(htmlMotion(), `「${tier.label}」段没有把档位写成 ${tier.key}`).toBe(tier.key);
206+
expect(window.localStorage.getItem(MOTION_INTENSITY_KEY)).toBe(tier.key);
207+
}
208+
});
209+
});
210+
211+
describe("V4 零行内 style(ADR-033 §4:不得用行内 style 覆盖类语义)", () => {
212+
it("控件**整棵子树**里没有一个元素带 `style` 属性(不只根节点)", () => {
213+
const { container } = render(<MotionIntensityControl />);
214+
const all = [...container.querySelectorAll<HTMLElement>("*")];
215+
expect(all.length, "子树为空 ⇒ 本判据会空真").toBeGreaterThan(2);
216+
expect(all.filter((el) => el.getAttribute("style") !== null).map((el) => el.tagName)).toEqual([]);
217+
});
218+
});
219+
220+
describe("DOM 契约(本件 Produces;计划 Interfaces)", () => {
221+
it("根 = `data-testid=\"motion-intensity\"` + `role=group` + 可访问名「动效强度」", () => {
222+
render(<MotionIntensityControl />);
223+
const root = screen.getByTestId("motion-intensity");
224+
expect(root.getAttribute("role")).toBe("group");
225+
expect(root.getAttribute("aria-label")).toBe("动效强度");
226+
});
227+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* MotionIntensityControl — 「动效强度」三段控件(规格 §8.5;R3.3 的 UI 入口;批 6 Task 7)。
3+
*
4+
* @ai-context Why:三档强度(节能 / 标准 / 丰富 ↔ `eco` / `standard` / `rich`)的**通道**由
5+
* `motion/intensity.ts`(T6)提供,但实测今天**没有外观设置页** ⇒ 没有入口时 §8.5 的三档只能靠
6+
* 改源码验证,即**不可验收**(R3.3 逐字)。本件就是那个入口:把 hook 的 `[档位, 选择]` 接到一个
7+
* **受控三段控件**上。三档对 CSS 的作用全在 `motion.css` 的 `html[data-motion="…"]` 块里(T6 已落)
8+
* —— 本件**不含任何动效逻辑**,只做「选择 → hook」,属性写入与持久化都在 hook 内。
9+
*
10+
* @ai-context ★ 为什么是「薄适配器」而不是自建三段控件(实测驱动;计划 Task 7「关键取舍」):
11+
* ① `app/src/components/**` 的非 test 文件落在 `nativeButton.ratchet` 的**域内**
12+
* (`nativeButton.ratchet.test.ts:143-145` 的 `isTest`/`isPrim`/`isIcons` 只排除
13+
* `*.test.(ts|tsx)` / `ui/primitives/**` / `ui/icons/**`)⇒ 本件若渲染原生 `button` 元素,
14+
* `FROZEN_NATIVE_BUTTON_TOTAL`(393)与逐文件冻结表会因**新文件**当场红;
15+
* ② `ui/primitives` 的 `ViewSwitcher` 已**逐字满足** R3.3 的三条形式要求:容器 `.ed-btn-group`
16+
* + 段 `.ed-btn--segment`(= 既有 `ed-btn` 段控件类名空间)、布尔 `aria-pressed` 承载选中态、
17+
* **零行内 style**(ADR-033 §4)⇒ 本件退化为适配器,零棘轮计数、零新类名、零新样式。
18+
* 诚实代价(登记):段控件的可定制性受 `ViewSwitcher` 约束(例如不支持图标段 / 不支持自定义段宽)。
19+
*
20+
* @ai-context Produces(新 DOM 契约):根元素 `data-testid="motion-intensity"`;容器 `role="group"`
21+
* + `aria-label="动效强度"`;三段文案逐字 = §8.5 的档名 节能 / 标准 / 丰富,与 `MOTION_INTENSITIES`
22+
* 的 `eco` / `standard` / `rich` **同序一一对应**(段清单从真源派生,不另立第二份)。
23+
* @ai-context 副作用:全部经 `useMotionIntensity` —— 挂载即写 `<html data-motion>`(取已存选择或系统
24+
* 初值),选择时写 `motion:intensity`。本件自身不碰 `document` / `localStorage` / `matchMedia`。
25+
* @ai-context 边界:`ViewSwitcher.onChange` 的实参是 `string`(通用原语不知道档位值域)⇒ 值域收口在
26+
* 这里:不在三档内的 key 一律**丢弃**(不写 DOM、不写记忆),避免脏值把 `data-motion` 写成非法档。
27+
*/
28+
import type { ReactElement } from "react";
29+
import { ViewSwitcher } from "../ui/primitives";
30+
import { MOTION_INTENSITIES, useMotionIntensity } from "../motion/intensity";
31+
import type { MotionIntensity } from "../motion/intensity";
32+
33+
/** 档 → 段名(逐字照 §8.5 的档名);用 `Record<MotionIntensity, string>` 让**漏档在 `tsc` 层即报错** */
34+
const LABELS: Readonly<Record<MotionIntensity, string>> = {
35+
eco: "节能",
36+
standard: "标准",
37+
rich: "丰富",
38+
};
39+
40+
/** 段清单 = 真源派生(段数 / 顺序 / 取值都不另立第二份;加档时这里自动跟上) */
41+
const OPTIONS = MOTION_INTENSITIES.map((key) => ({ key, label: LABELS[key] }));
42+
43+
/** 值域收口:原语只承诺 `string`,档位判定留在本层(越界 key ⇒ 静默丢弃) */
44+
const isIntensity = (key: string): key is MotionIntensity =>
45+
(MOTION_INTENSITIES as readonly string[]).includes(key);
46+
47+
export function MotionIntensityControl(): ReactElement {
48+
const [intensity, select] = useMotionIntensity();
49+
return (
50+
<ViewSwitcher
51+
options={OPTIONS}
52+
value={intensity}
53+
onChange={(key) => {
54+
if (isIntensity(key)) select(key);
55+
}}
56+
ariaLabel="动效强度"
57+
testId="motion-intensity"
58+
/>
59+
);
60+
}

‎app/src/pages/SettingsPage.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import AsrConfusionPanel from "../components/AsrConfusionPanel";
3333
import ProofreadToggle from "../components/ProofreadToggle";
3434
// v0.20.4(REQ-304):web 扩展收件服务面板
3535
import WebInboxPanel from "../components/WebInboxPanel";
36+
// 批 6 T7(R3.3 / 规格 §8.5):动效强度三档入口——没有入口则「三档正确」不可验收
37+
import { MotionIntensityControl } from "../components/MotionIntensityControl";
3638
// 批 3(规格 §6.2「设置 主列:现 720 左对齐 → 改居中(860)」):阅读舒适宽取自注册表。
3739
// ⚠️ 本列**不走** useColumnLayout——注册表它的 min=max=0(不参与拖拽),
3840
// clamp(860, 0, 0) 会得到 0 ⇒ 只取规格的 default 值
@@ -136,6 +138,12 @@ export default function SettingsPage({ active = true }: Props) {
136138
<WebInboxPanel />
137139
</div>
138140

141+
{/* 批 6 T7(R3.3 / §8.5):外观组——今天只此一项;完整外观/主题设置页属批 7/8 */}
142+
<GroupTitle>外观</GroupTitle>
143+
<div style={{ ...panel, marginBottom: 4 }}>
144+
<MotionIntensityControl />
145+
</div>
146+
139147
{/* ── 功能预览(默认关的新能力开关,v4 §11.3)── */}
140148
<GroupTitle>功能预览</GroupTitle>
141149
<div style={{ ...panel, marginBottom: 4 }}>

0 commit comments

Comments
 (0)