Skip to content

Commit 0ea19a7

Browse files
committed
refactor(session): 收敛播放头为容器单一真源
1 parent 9d3dfa5 commit 0ea19a7

2 files changed

Lines changed: 60 additions & 25 deletions

File tree

‎app/src/views/session/SessionTriTrackView.rail.test.tsx‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* 与 `data-degraded` 这类**结构事实**;真实播放 / seek 在本环境不可验证(不声称)。
1414
* 副作用:只挂 React 树;不写盘、不发请求、不真播放。
1515
*/
16+
import { useState } from "react";
17+
import type { ReactElement } from "react";
1618
import { cleanup, fireEvent, render } from "@testing-library/react";
1719
import { afterEach, describe, expect, it, vi } from "vitest";
1820
import type { SessionAudioState, SessionDetail } from "../../types/session";
@@ -54,6 +56,7 @@ interface Over {
5456
readonly playheadMs?: number | null;
5557
readonly detail?: SessionDetail;
5658
}
59+
/** 展示形态:`playheadMs` 是**静态注入值**(视图受控 ⇒ 点击不会自己动它) */
5760
function mountView(over: Over = {}) {
5861
const onSeekMs = vi.fn();
5962
const view = render(
@@ -66,6 +69,35 @@ function mountView(over: Over = {}) {
6669
);
6770
return { ...view, onSeekMs };
6871
}
72+
73+
/**
74+
* 受控夹具:**逐字镜像容器**(`SessionDetailPanel`)的回路 —— 点时间码 ⇒ 上报 ⇒ `setState`
75+
* ⇒ 下一帧 `playheadMs` 回来。视图自身零状态 ⇒ 没有这个回路就没有位移(判据 I3/I4 的因)。
76+
*/
77+
function Controlled({ detail, audio, onSeek }: { readonly detail: SessionDetail; readonly audio: SessionAudioState | null | undefined; readonly onSeek: (ms: number) => void }): ReactElement {
78+
const [ms, setMs] = useState<number | null>(null);
79+
return (
80+
<SessionTriTrackView
81+
detail={detail}
82+
audio={audio}
83+
playheadMs={ms}
84+
onSeekMs={(next) => {
85+
onSeek(next);
86+
setMs(next);
87+
}}
88+
/>
89+
);
90+
}
91+
function mountControlled(over: Over = {}) {
92+
const onSeekMs = vi.fn();
93+
const view = render(<Controlled detail={over.detail ?? detailOf()} audio={"audio" in over ? over.audio : OK} onSeek={onSeekMs} />);
94+
return { ...view, onSeekMs };
95+
}
96+
/** 未接线形态:**完全不传** `onSeekMs`(宿主没接上上行口时的实际形态) */
97+
function mountUnwired(over: Over = {}) {
98+
const view = render(<SessionTriTrackView detail={over.detail ?? detailOf()} audio={"audio" in over ? over.audio : OK} playheadMs={over.playheadMs ?? null} />);
99+
return view;
100+
}
69101
function q(container: HTMLElement, sel: string): HTMLElement {
70102
const el = container.querySelector(sel);
71103
if (el === null) throw new Error(`没有渲染出 ${sel}`);
@@ -115,9 +147,9 @@ describe("I2 总长真源 `totalMsOf`:三数组取大,且有 `durationMs`
115147
});
116148
});
117149

118-
describe("I3 时间码回跳:点时间码 ⇒ 上行请求**恰一次**且播放头滑到该段", () => {
119-
it("点 9000ms 的时间码 ⇒ onSeekMs(9000) 恰 1 次 + 播放头 data-ms=9000;点另一段 ⇒ 逐字跟随", () => {
120-
const { container, onSeekMs } = mountView({ audio: NO_AUDIO });
150+
describe("I3 时间码回跳:点时间码 ⇒ 上行请求**恰一次**,回传后播放头滑到该段", () => {
151+
it("受控回路:点 9000 ⇒ onSeekMs(9000) 恰 1 次 + 播放头 data-ms=9000;再点 3725000 ⇒ 逐字跟随", () => {
152+
const { container, onSeekMs } = mountControlled({ audio: NO_AUDIO });
121153
fireEvent.click(codeAt(container, 9_000));
122154
expect(onSeekMs.mock.calls.length, "点击没有(或重复)上报").toBe(1);
123155
expect(onSeekMs.mock.calls[0][0]).toBe(9_000);
@@ -126,19 +158,25 @@ describe("I3 时间码回跳:点时间码 ⇒ 上行请求**恰一次**且播
126158
expect(container.querySelector(PLAYHEAD)?.getAttribute("data-ms")).toBe("3725000");
127159
// 反例守卫:不点 ⇒ 无播放头(位置不是常量、也不是「第一段」)
128160
cleanup();
129-
const fresh = mountView({ audio: NO_AUDIO });
161+
const fresh = mountControlled({ audio: NO_AUDIO });
130162
expect(fresh.container.querySelector(PLAYHEAD)).toBeNull();
131163
expect(fresh.onSeekMs.mock.calls.length).toBe(0);
132164
});
133165
});
134166

135-
describe("I4 注入 `playheadMs` 优先于本地聚焦(T26 深链的先后次序)", () => {
136-
it("注入 52500 后点 9000 的时间码 ⇒ 上行仍收到 9000,但播放头停在注入值", () => {
167+
describe("I4 受控语义:视图**不自持**播放头状态(单一真源在容器 —— T24 的槽位契约)", () => {
168+
it("静态注入 52500:点 9000 的时间码只上报、播放头**不动**;未接线(不传 onSeekMs)⇒ 点击不产生播放头", () => {
137169
const { container, onSeekMs } = mountView({ audio: OK, playheadMs: 52_500 });
138170
expect(q(container, PLAYHEAD).dataset.ms).toBe("52500");
139171
fireEvent.click(codeAt(container, 9_000));
140-
expect(onSeekMs.mock.calls[0][0]).toBe(9_000);
141-
expect(q(container, PLAYHEAD).dataset.ms, "本地点击把注入的播放头覆盖掉了(深链会被旧聚焦吞掉)").toBe("52500");
172+
expect(onSeekMs.mock.calls[0][0], "点击没有上报").toBe(9_000);
173+
expect(q(container, PLAYHEAD).dataset.ms, "视图自持了第二份播放头状态(受控契约被破坏)").toBe("52500");
174+
// 未接线:宿主没给出上行口 ⇒ 点击不产生任何位移(也不抛错)
175+
cleanup();
176+
const unwired = mountUnwired({ audio: NO_AUDIO });
177+
fireEvent.click(codeAt(unwired.container, 9_000));
178+
expect(unwired.container.querySelector(PLAYHEAD), "视图自己动起来了(第二份真源)").toBeNull();
179+
expect(unwired.container.querySelector(RAIL), "未接线把时间轨也带走了").not.toBeNull();
142180
});
143181
});
144182

@@ -149,13 +187,13 @@ describe("I5 音频槽在视图侧的落点与降级可用性(无音频 ⇒
149187
expect(withAudio.container.querySelectorAll('[data-kind="error"]')).toHaveLength(0);
150188
cleanup();
151189

152-
const noAudio = mountView({ audio: NO_AUDIO });
190+
const noAudio = mountControlled({ audio: NO_AUDIO });
153191
const root = q(noAudio.container, RAIL);
154192
expect(root.getAttribute("data-degraded"), "无音频没有进降级态").toBe("true");
155193
expect(noAudio.container.querySelector('[data-testid="session-timerail-viewport"]'), "降级把时间轨带走了").not.toBeNull();
156194
expect(noAudio.container.querySelectorAll('[data-kind="error"]')).toHaveLength(1);
157195
expect(noAudio.container.querySelector("audio"), "无音频却渲染了媒体元素").toBeNull();
158-
// 降级下「时间轨仍可用」的可判部分:点时间码照旧驱动播放头
196+
// 降级下「时间轨仍可用」的可判部分:点时间码照旧驱动播放头(受控回路)
159197
fireEvent.click(codeAt(noAudio.container, 9_000));
160198
expect(q(noAudio.container, PLAYHEAD).dataset.ms).toBe("9000");
161199
});

‎app/src/views/session/SessionTriTrackView.tsx‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@
77
* (C17① ⇒ 批 5 笔记侧只交付原文 + 卡片流两种形式)。故本件是「双通道 + 画面的文字面」。
88
* @ai-context 依赖方向(规格 §7.1 / C14②):本件是**纯展示视图** —— 它的第一条判据是
99
* 「**不 invoke**」。三条轨与时间轨的**原始数据与一切派生**都从 props 来;本件
10-
* **零 `@tauri-apps` import、零 `invoke`、零取数、零 I/O**。批 6 T25 起多出两样**纯本地**的
11-
* 东西(都不取数、不写盘、不发请求):① 一个 `useState` = 用户点时间码后的聚焦 ms
12-
* (降级时它就是「当前聚焦段」指示器的位置);② 子件 `TimeRail` 的 `useEffect`
13-
* (元素级滚动把播放头送到目标位置)。
10+
* **零 `@tauri-apps` import、零 `invoke`、零 `useState`、零取数、零 I/O**(唯一的副作用是
11+
* 把点击上报给注入的 `onSeekMs`,与 `viewKey`/`onViewKeyChange` 同一种**受控**形态)。
1412
* @ai-context 对齐口径(计划 Task 7 Step 1 写死):三条轨共用**同一条 ms 轴**,逐轨按 ms 升序;
1513
* 同一 ms 时 DOM 顺序固定 **转写 → 画面 → OCR**(判据用 `compareDocumentPosition` 钉住)。
1614
* 轨的时间锚 = `segments.start_ms` / `screens.first_seen_ms` / `ocr_blocks.timestamp_ms`。
@@ -27,7 +25,11 @@
2725
* ④ 零交互 ⇒ 0 个 `<button>`(`nativeButton.ratchet` 域含 `views/**`)。
2826
* @ai-context **批 6 T25:时间轨 + 播放头 + 优雅降级(R5.5 #5 的承载面)**。本件在三条轨**之上**
2927
* 加一条共享的横向时间轨(`TimeRail`;总长的真源见 `totalMsOf`),播放头位置 = 注入槽
30-
* `playheadMs`,**否则**退化为用户点过的聚焦段(本地 `activeMs`)。
28+
* `playheadMs`(**唯一真源在容器**)。
29+
* 🔴 **播放头是受控的**:本件**不自持**第二份播放头状态(T24 的槽位契约逐字「容器负责
30+
* `setState` + 播放头动效,**视图自身零副作用**」)⇒ 用户点时间码只经 `onSeekMs` 上报,
31+
* 位置随下一帧的 `playheadMs` 回来。降级时的「当前聚焦段」指示器因此也由**容器**驱动
32+
* —— 不传 `onSeekMs` 的宿主点时间码不会有任何位移(判据 I3 钉住这一点)。
3133
* 🔴 **音频与可播 URL 一律由容器注入**(`SessionViewSlot.audio`):本件不取数、不拼 URL
3234
* —— `views/**` 是零 Tauri 边(A3③)。🔴 **播放只许 `<audio src>`**:`fetch()` 整取音频会把
3335
* 约 115 MB/小时读进内存(R5.5-b 约束 2)。🔴 **只有已 finalize 的 WAV 可播**(约束 3)
@@ -41,15 +43,14 @@
4143
* @ai-context 与「印样」视图的边界(计划 Task 8 Step 1 的逐字要求在这里登记):**本件不渲染图片**。
4244
* 配图需要容器注入的 `imageUrl()` 槽(本件不接该槽),且「一屏一段区间 + 配图」是印样视图的形态;
4345
* 三轨对齐只需要「时间 + 这一条是什么」⇒ 画面轨只出 `title`/`body` 文本与区间时间码。
44-
* 副作用:无(纯函数式渲染;无 I/O、无订阅、无定时器、无全局单例)。T25 增:一次用户点击后的
45-
* `setState`(聚焦 ms)+ 子件 `TimeRail` 的一次元素级滚动写入。
46+
* 副作用:无(纯函数式渲染;无 I/O、无订阅、无定时器、无本地 state、无全局单例)。T25 增:
47+
* 子件 `TimeRail` 的一次元素级滚动写入(DOM 属性,随 `playheadMs` 变化)。
4648
* 边界:① `detail` 三个数组**都空** ⇒ 整块换成 `EmptyState`(根 `data-testid` 仍在,形态可判)
4749
* —— 此时**不出时间轨**(刻度的三个来源全空 ⇒ 轨上无内容可指);
4850
* ② 单轨为空时该轨**照常渲染**(列头在、列内 0 条)—— 「三轨对齐」的语义是「三轨同框」,
4951
* 缺一条轨不等于整块没内容,故**不用**整块空态顶掉(否则用户看不到"哪一轨是空的");
5052
* ③ 本件不做虚拟滚动(数据量由容器与产品上限决定;长列表的滚动/密度手感见批 6)。
5153
*/
52-
import { useState } from "react";
5354
import type { CSSProperties, ReactElement } from "react";
5455
import { Button, EmptyState, Text } from "../../ui/primitives";
5556
import { fmtMs } from "../../utils/fmt";
@@ -221,12 +222,9 @@ function TriTrackItem({ item, onSeek }: { readonly item: AlignedItem; readonly o
221222

222223
/**
223224
* 渲染会话三轨对齐视图 + 时间轨(T25)。数据全来自 props(`detail` + 三个注入槽)
224-
* ⇒ 本件不取数;唯一的本地状态是「用户点过的聚焦段」。
225+
* ⇒ 本件不取数、**不自持播放头状态**(受控:点击只上报,位置随 `playheadMs` 回来)。
225226
*/
226227
export default function SessionTriTrackView({ detail, audio, playheadMs, onSeekMs }: Slot): ReactElement {
227-
// 「当前聚焦段」(T25 Step 3 的 `activeMs`):由用户输入驱动。**注入的 `playheadMs` 优先**
228-
// —— 容器(T26 的深链)一旦给出目标,它就必须赢过本地点击,否则深链会被旧聚焦吞掉。
229-
const [activeMs, setActiveMs] = useState<number | null>(null);
230228
const lanes = lanesOf(detail);
231229
const empty = lanes.every((lane) => lane.items.length === 0);
232230

@@ -242,16 +240,15 @@ export default function SessionTriTrackView({ detail, audio, playheadMs, onSeekM
242240
);
243241
}
244242

245-
/** 时间码点击:本地立即定位(无容器也看得见)**并**经唯一上行口请求容器同步 */
243+
/** 时间码点击:**只**经唯一上行口请求容器定位(视图自持状态 = 第二份真源,禁止) */
246244
const seek = (ms: number): void => {
247-
setActiveMs(ms);
248245
onSeekMs?.(ms);
249246
};
250247

251248
return (
252249
<div style={ROOT_STYLE} data-testid="session-tritrack-view">
253250
{/* R5.5 #5 的承载面:共享时间轴尺 + 播放头 + `<audio>` 属性契约 + 降级提示行 */}
254-
<TimeRail totalMs={totalMsOf(detail, audio)} playheadMs={playheadMs ?? activeMs} audio={audio} onSeekMs={onSeekMs} />
251+
<TimeRail totalMs={totalMsOf(detail, audio)} playheadMs={playheadMs ?? null} audio={audio} onSeekMs={onSeekMs} />
255252
<div style={TRACKS_STYLE}>
256253
{lanes.map((lane) => (
257254
<div key={lane.track} style={COLUMN_STYLE}>

0 commit comments

Comments
 (0)