Skip to content

Commit c518238

Browse files
committed
feat(motion): 落课后显影编排签名动效
1 parent 48e453e commit c518238

4 files changed

Lines changed: 710 additions & 3 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionRawView.test.tsx — **#2「显影编排」的落点判据 + R11.3 共存判据**(批 6 波 C · T28)。
4+
*
5+
* @ai-context Why 新建而不是追加:本视图**批 5 起就没有同名测试**(实测:全仓只有
6+
* `components/SessionDetailPanel.test.tsx` 经真面板间接渲染它)⇒ 显影的落点与低置信墨度的
7+
* 落点都没有**直接**判据。本文件把两件事变成机器判据:
8+
* ① **落点 = 课后**(R5.2 逐字):显影的容器(`data-tone="paper"`)与逐段锚点
9+
* (`data-seg-id`)都在**本视图的产物**里,且 `[data-seg-id]` 与容器内的锚点**逐序数组相等**;
10+
* ② **与 R11.3 共存**(本任务最重要的一条):低置信段落**同时**带 `ed-text--low-confidence`
11+
* (CSS `animation` 动 `opacity`)**与**显影写的内联 `transform`,且两处**互不覆盖** ——
12+
* 机器形态 = 「类名仍在 ∧ `style.transform` 有值 ∧ `style.opacity` 为空(显影从不染指墨度)」。
13+
* 变异 M7(把显影改成动 `opacity`)就是死在这一条上。
14+
* ⚠️ 既有 DOM 与文案**一条不许改**:本文件逐字断言 `seg-{sessionId}-{segId}` 锚点(`SessionDetailPanel
15+
* .test.tsx` 的 P1/P2 依赖它)、`fmtMs` 时间码与三处来源文案。
16+
*
17+
* @ai-context 仪器边界:只 mock **模块边界**(两个带 IPC / asset 的子块),**不 mock** 被测件与
18+
* `useRevealChoreography` —— 显影的物化走真 hook + 真的动态 `import("../../motion/controls")`。
19+
* 时间线在本文件里是**生产时基**(视图不传 `paused`)⇒ 只断「有值 / 类名 / 逐序数组」这类
20+
* **非时序**读数(数值与时序判据在 `useRevealChoreography.test.tsx` 的 paused 时基上做)。
21+
*
22+
* 副作用:挂/卸真实 DOM、建 GSAP 时间线(`afterEach` 收尸)、读写档位记忆(`localStorage`)。
23+
* 边界:**观感与真实帧率本批未测**(jsdom 不排版、无 paint)—— 本文件不判「显影看起来如何」。
24+
*/
25+
import { cleanup, render, waitFor } from "@testing-library/react";
26+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
27+
import { gsap } from "../../motion/engine";
28+
import { MOTION_INTENSITY_KEY } from "../../motion/intensity";
29+
import { tweenCount } from "../../test/motionHarness";
30+
import type { SessionDetail } from "../../types";
31+
import { fmtMs } from "../../utils/fmt";
32+
33+
vi.mock("./SessionScreenCards", () => ({ default: () => <div data-testid="screen-cards" /> }));
34+
vi.mock("../ImageGallery", () => ({ default: () => <div data-testid="image-gallery" /> }));
35+
36+
import SessionRawView from "./SessionRawView";
37+
38+
const LOW = "ed-text--low-confidence";
39+
const noop = () => {};
40+
/** 三段:中置信 / **低置信(0.3 < 0.5)** / 无置信度(null ⇒ 不算低置信) */
41+
const SEGMENTS = [
42+
{ id: 7001, session_id: 1042, start_ms: 0, end_ms: 2500, text: "第一段讲述", source: "subtitle", confidence: 0.9 },
43+
{ id: 7002, session_id: 1042, start_ms: 2500, end_ms: 5000, text: "第二段低置信讲述", source: "asr", confidence: 0.3 },
44+
{ id: 7003, session_id: 1042, start_ms: 5000, end_ms: 8000, text: "第三段讲述", source: "fused", confidence: null },
45+
];
46+
const detailOf = (segments = SEGMENTS): SessionDetail => ({
47+
session: { id: 1042, title: "化学反应速率", source_window: "Chrome", started_at: 1_700_000_000_000, ended_at: 1_700_000_600_000, status: "finished", kind: null },
48+
segments,
49+
ocr_blocks: [],
50+
screens: [],
51+
});
52+
53+
function renderView(detail: SessionDetail = detailOf()): ReturnType<typeof render> {
54+
return render(
55+
<SessionRawView
56+
detail={detail}
57+
baseUrl=""
58+
ocrBlocksByScreen={new Map()}
59+
selectingScreen={null}
60+
onSelectScreen={noop}
61+
panelToast={null}
62+
onShowToast={noop}
63+
onClearToast={noop}
64+
glossarySummary="术语 0 条"
65+
glossary={[]}
66+
/>,
67+
);
68+
}
69+
70+
const toneBox = (): HTMLElement => document.querySelector<HTMLElement>('[data-tone="paper"]') as HTMLElement;
71+
const rows = (): HTMLElement[] => [...document.querySelectorAll<HTMLElement>("[data-seg-id]")];
72+
const rowIdOf = (el: HTMLElement): string => el.getAttribute("data-seg-id") ?? "";
73+
/** 段落正文(`<Text as="span">` = 行内第三列;低置信类名就落在它身上) */
74+
const textOf = (row: HTMLElement): HTMLElement => row.lastElementChild as HTMLElement;
75+
const hasLow = (row: HTMLElement): boolean => textOf(row).classList.contains(LOW);
76+
77+
beforeEach(() => {
78+
localStorage.setItem(MOTION_INTENSITY_KEY, "standard");
79+
});
80+
afterEach(() => {
81+
cleanup();
82+
for (const el of [...document.body.children]) {
83+
gsap.killTweensOf(el);
84+
el.remove();
85+
}
86+
});
87+
88+
describe("V1 · 显影的落点 = 课后本视图(R5.2):容器基调 + 逐段锚点 + 既有 DOM/文案一字未改", () => {
89+
it("落点:`data-tone=\"paper\"` 容器内**逐序**恰 N 个 `data-seg-id`(数量与顺序都是判据)", async () => {
90+
const { container } = renderView();
91+
await waitFor(() => expect(rows().length, "逐段锚点没落地 ⇒ 显影没有可作用的目标").toBe(SEGMENTS.length));
92+
expect(rows().map(rowIdOf), "逐序数组相等(不是「有 N 个」)").toEqual(SEGMENTS.map((s) => String(s.id)));
93+
expect([...container.querySelectorAll<HTMLElement>('[data-tone="paper"] [data-seg-id]')].map(rowIdOf), "锚点必须全在基调容器**内**(落点不是别处)").toEqual(SEGMENTS.map((s) => String(s.id)));
94+
expect(toneBox().getAttribute("data-tone"), "R3.4:会话是「有文字的界面」⇒ 活的纸").toBe("paper");
95+
// 显影真的把起始态物化到了这些锚点上(不是「锚点在、动效没接上」)
96+
await waitFor(() => expect(rows().every((r) => r.style.transform !== ""), "显影没有写这些段落的 transform").toBe(true), { timeout: 5000 });
97+
expect(toneBox().getAttribute("data-reveal-epoch"), "持有的世代号在 DOM 上的锚(R8.5)").toBe("0");
98+
});
99+
100+
it("既有 DOM 与文案一条不改:`seg-{sessionId}-{segId}` 锚点 / 时间码 / 三处来源文案逐字在位", () => {
101+
renderView();
102+
const detail = detailOf();
103+
expect([...document.querySelectorAll("[id^='seg-']")].map((el) => el.id), "批 5 的段锚点(SessionDetailPanel P1/P2 依赖)").toEqual(detail.segments.map((s) => `seg-1042-${s.id}`));
104+
const rowsText = rows().map((r) => r.textContent ?? "");
105+
expect(rowsText.map((t) => detail.segments.map((s) => t.includes(s.text)).includes(true)), "每段正文逐字在 DOM 里").toEqual(SEGMENTS.map(() => true));
106+
expect(rowsText[0], "时间码走 `fmtMs` 的唯一出口(既有文案)").toContain(`${fmtMs(0)} – ${fmtMs(2500)}`);
107+
expect(rowsText.map((t) => ["字幕", "语音", "融合"].find((label) => t.includes(label))), "三处来源标记文案逐序").toEqual(["字幕", "语音", "融合"]);
108+
});
109+
});
110+
111+
describe("V2 · 与 R11.3 共存:低置信墨度(CSS animation)与显影(GSAP transform)互不覆盖", () => {
112+
it("低置信段**恰 1 个**带 `ed-text--low-confidence`(逐序数组相等),且同段行上带显影的内联 `transform`", async () => {
113+
renderView();
114+
await waitFor(() => expect(rows().every((r) => r.style.transform !== "")).toBe(true), { timeout: 5000 });
115+
expect(rows().map(hasLow), "只有 confidence = 0.3 的那段是低置信(0.9 / null 都不算)").toEqual([false, true, false]);
116+
expect(document.querySelectorAll(`.${LOW}`), "低置信落点的计数(R11.3 ③ 的环境层落点)").toHaveLength(1);
117+
const low = rows()[1] as HTMLElement;
118+
// 🔴 共存双断言:**同一个段落**上既有 CSS animation 的类名,又有显影写的内联 transform
119+
// ⚠️ 本落点取**嵌套形态**(与计划 `:1995` 的「共用一个元素」逐字不同 —— 理由逐字登记在
120+
// `task-28-report.md`:正文是 `<span>`(`display: inline`),而 `transform` **对行内元素无效**
121+
// ⇒ 把显影挂在正文上会是一条**看不见的动画**;改挂**段落行**(`<div>`)后两个动效既不共元素、
122+
// 也不共属性 ⇒ 「互不覆盖」比计划要求的更强)。
123+
expect(hasLow(low), "M7(显影改成动 opacity)会连带打掉这条链").toBe(true);
124+
expect(low.style.transform, "显影的可见通道 = 段落行的 `transform`(不是 `opacity`)").not.toBe("");
125+
expect(low.contains(textOf(low)), "低置信类名必须在显影目标**之内**(同段落的两个动效)").toBe(true);
126+
for (const row of rows()) {
127+
expect(row.style.opacity, "🔴 显影**一个 `opacity` 都不写** ⇒ 墨度通道归 R11.3 的 CSS `animation` 独占").toBe("");
128+
expect(textOf(row).style.opacity, "墨度层(正文)的内联 `opacity` 也必须为空 ⇒ 零覆盖").toBe("");
129+
expect(tweenCount(textOf(row)), "正文元素上不许挂 tween(显影的唯一目标是段落行)").toBe(0);
130+
}
131+
});
132+
});
133+
134+
describe("V3 · 空态与安全早退(零段落 ⇒ 不建时间线、不抛)", () => {
135+
it("零段落:空态文案在位 · 零锚点 · 零 tween · 基调容器与世代锚仍在", async () => {
136+
renderView(detailOf([]));
137+
expect(rows(), "零段落不该有逐段锚点").toHaveLength(0);
138+
expect(toneBox().getAttribute("data-tone"), "空态下基调容器照旧声明").toBe("paper");
139+
expect(document.body.textContent, "既有空态文案(photo 会话另一支)").toContain("本会话无转写段");
140+
expect(tweenCount(toneBox()), "零段落 ⇒ 不许建编排时间线").toBe(0);
141+
expect(gsap.globalTimeline.getChildren(false, true, true), "空态下场上没有编排时间线").toHaveLength(0);
142+
});
143+
});

‎app/src/components/session-detail/SessionRawView.tsx‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@
2121
* ③ 术语表 `<details>` 的整圈边框用 **token 变量** `var(--ed-border)` —— 不迁
2222
* `Surface`:`Surface.css` 是 `.ed-surface` 类族,而本元素是 `<details>` 折叠块
2323
* (`SurfaceTag` 不含 `details`;加档=改原语契约,不在本任务范围)。
24+
* @ai-context: **批 6 T28 —— #2「显影编排」(§8.6 第 2 行)的落点就在本文件**:R5.2 逐字把落点裁在
25+
* **课后**(`detail.segments.map`,即 `:87-102` 那一段),采集期落点
26+
* (`LiveActivityPanel`)**登记转批 7**。本件只做三件事,**不改**既有 DOM 结构与文案:
27+
* ① 段落容器加 `ref` + `data-tone="paper"`(§8.3:会话是「有文字的界面」);
28+
* ② 每段加 `data-seg-id`(`useRevealChoreography` 的逐段锚点 = `[data-seg-id]`);
29+
* ③ 段正文 `<Text>` 追加 `lowConfidenceClass(seg.confidence)` —— 这是 R11.3 /
30+
* R12.4 点名要落地的**低置信墨度起伏**(环境层第 ③ 件)的**生产过程调用点**:
31+
* 它与显影**共用同一个元素**,所以两者必须**互不覆盖** —— 那条 CSS `animation` 动
32+
* `opacity`,显影的 GSAP 只动 `transform`(见 `useRevealChoreography.ts` 的文件头)。
33+
* ④ `data-reveal-epoch` 把「持有的世代号」暴露到 DOM(R8.5:结构锚点优先于时间判据)。
34+
* ⚠️ 因此本件新增一条 import 边:`components/structuredBlocks.ts`(它顶层 `import katex`
35+
* + `katex.min.css`)⇒ **KaTeX 会成为会话详情惰性 chunk 的依赖**(首屏静态闭包不含本件,
36+
* `check-bundle-budget` 的首屏口径不变;代价逐字登记在 `task-28-report.md` 的诚实边界)。
2437
* @ai-context: **逐条自审(控制方回执③)后仍保留的字面量**:`fontSize: 13`(三处 `<h3>` 标题,
2538
* 合法档 —— 规格 §4.2 字阶下界是 12px)· `fontSize: 12`(术语表折叠块)·
2639
* `borderRadius: 8`(= `radiusScale.panel` 档)· `background: "#fafafa"`(**未迁移**:
@@ -29,8 +42,11 @@
2942
* 等**逐字继承自原面板、未被我改写**且**不在任何棘轮集合内**的字面量。⇒ 五类棘轮
3043
* (圆角 / 字号 / 弱化灰 / 卡片边框 / 三红)在本件全 0 命中。
3144
*/
45+
import { useRef } from "react";
3246
import SessionScreenCards from "./SessionScreenCards";
3347
import ImageGallery from "../ImageGallery";
48+
import { lowConfidenceClass } from "../structuredBlocks";
49+
import { useRevealChoreography } from "./useRevealChoreography";
3450
import type { GlossaryTerm, SessionDetail, SessionOcrBlock } from "../../types";
3551
import { fmtMs } from "../../utils/fmt";
3652
import { Text } from "../../ui/primitives";
@@ -73,19 +89,35 @@ export default function SessionRawView({
7389
glossary,
7490
}: Props) {
7591
const sessionId = detail.session.id;
92+
// 🔴 #2「显影编排」的挂载点(R5.2 的课后落点)。本件**只提供容器与锚点**:起始态物化、逐段
93+
// 错开、可中断/可反向全在 hook 里(视图保持零副作用 —— 与 T25 的容器单一真源同一条纪律)。
94+
const revealBox = useRef<HTMLDivElement | null>(null);
95+
const reveal = useRevealChoreography(revealBox, detail.segments);
7696
return (
7797
<>
7898
{/* 转写时间轴(字幕为主,语音/融合弱化;段 id 锚点供大纲/搜索跳转) */}
7999
<h3 style={{ fontSize: 13, margin: "12px 0 6px" }}>转写时间轴</h3>
80-
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
100+
{/* `data-tone="paper"` = #2 的基调登记(R3.4 逐字「每个动效落点显式声明基调」);
101+
`data-reveal-epoch` = 持有的重播世代(R8.5 的结构锚点) */}
102+
<div
103+
ref={revealBox}
104+
data-tone="paper"
105+
data-reveal-epoch={reveal.revealEpoch}
106+
style={{ display: "flex", flexDirection: "column", gap: 4 }}
107+
>
81108
{/* v0.11.7:图文会话空态语义区分(无讲述内容);其余会话维持原文案 */}
82109
{detail.segments.length === 0 && (
83110
<Text as="p" size={5} tone="ink-3">
84111
{detail.session.kind === "photo" ? "本会话无讲述内容(图文采集)" : "本会话无转写段"}
85112
</Text>
86113
)}
87114
{detail.segments.map((seg) => (
88-
<div key={seg.id} id={`seg-${sessionId}-${seg.id}`} style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
115+
<div
116+
key={seg.id}
117+
id={`seg-${sessionId}-${seg.id}`}
118+
data-seg-id={seg.id}
119+
style={{ display: "flex", gap: 8, alignItems: "baseline" }}
120+
>
89121
<Text tone="ink-3" style={{ width: 78, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
90122
{fmtMs(seg.start_ms)} – {fmtMs(seg.end_ms)}
91123
</Text>
@@ -95,7 +127,15 @@ export default function SessionRawView({
95127
>
96128
{SOURCE_LABEL[seg.source] ?? seg.source}
97129
</Text>
98-
<Text as="span" size={5} tone="ink-2" style={{ color: seg.source === "fused" ? "var(--ed-due)" : undefined }}>
130+
{/* 低置信段(`confidence < 0.5`,阈值真源 = `structuredBlocks.lowConfidenceClass`)追加
131+
`ed-text--low-confidence`(R11.3 ③ 的环境层落点;样式规则在 `ui/primitives/Text.css`)。 */}
132+
<Text
133+
as="span"
134+
size={5}
135+
tone="ink-2"
136+
className={lowConfidenceClass(seg.confidence)}
137+
style={{ color: seg.source === "fused" ? "var(--ed-due)" : undefined }}
138+
>
99139
{seg.text}
100140
</Text>
101141
</div>

0 commit comments

Comments
 (0)