Skip to content

Commit 9d3dfa5

Browse files
committed
feat(session): 加播放头与音频降级提示
1 parent 103703e commit 9d3dfa5

7 files changed

Lines changed: 719 additions & 96 deletions

File tree

‎app/src/components/SessionDetailPanel.test.tsx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,53 @@ describe("SessionDetailPanel · 安全网(批 5 T2 Step 1;抽件前后逐字
183183
function findButtonSafe(label: string): HTMLButtonElement | null {
184184
return ([...document.querySelectorAll("button")].find((b) => b.textContent?.includes(label)) as HTMLButtonElement | undefined) ?? null;
185185
}
186+
187+
/**
188+
* 批 6 T25 的**容器侧接线**判据(控制方授权①的唯一落点)。
189+
*
190+
* @ai-context Why 落在这里而不是宿主测试:宿主 `SessionViewHost` 有一条既有判据 **H0**
191+
* 「宿主自身不 invoke」(它的测试文件余 7 行 ⇒ 不许再追加)⇒ 取数与播放头状态只能落在面板这一层。
192+
* 本块判的就是授权①的兑现:`useSessionAudio` 的产出**在生产路径上**进得了注入槽
193+
* (W1),且 `playheadMs`/`onSeekMs` 的回路真的闭环(W2)。
194+
* @ai-context 仪器边界:`convertFileSrc` 在本文件顶部被 mock 成 `asset://localhost/<path>`
195+
* (真 Tauri 不在场);媒体行为不触发(不点播放控件)⇒ 不真播放。
196+
*/
197+
describe("SessionDetailPanel · 批 6 T25 容器接线(音频槽在生产路径上有值)", () => {
198+
/** 真实形态的应用数据目录内路径(T22 的出参 `path`;URL 由前端 `convertFileSrc` 拼) */
199+
const AUDIO_PATH = "C:\\Users\\u\\AppData\\Roaming\\com.entropydecrease.app\\session-audio\\1042.wav";
200+
201+
beforeEach(() => {
202+
// 视图记忆(`view:default:session`)在同文件的用例间共享(P1–P4 会写它)⇒ 先清干净
203+
localStorage.clear();
204+
invokeMock.mockImplementation((cmd: string) =>
205+
cmd === "session_audio_path"
206+
? Promise.resolve({ path: AUDIO_PATH, aligned: true, durationMs: 3_730_000 })
207+
: Promise.reject(new Error(`invoke not mocked: ${cmd}`)),
208+
);
209+
});
210+
211+
it("W1 `session_audio_path` 恰 1 次(实参逐字)⇒ `<audio src>` = convertFileSrc(path) 且播放控件可用", async () => {
212+
const { container } = render(<SessionDetailPanel {...propsOf(detailOf())} />);
213+
fireEvent.click(findButton("三轨对齐"));
214+
await screen.findByTestId("session-tritrack-view");
215+
const audio = await screen.findByTestId("session-timerail-audio");
216+
expect(
217+
invokeMock.mock.calls.filter((c) => c[0] === "session_audio_path"),
218+
"音频引用的取数点不是唯一 / 实参漂了",
219+
).toEqual([["session_audio_path", { sessionId: 1042 }]]);
220+
expect(audio.getAttribute("src")).toBe(`asset://localhost/${AUDIO_PATH}`);
221+
expect((container.querySelector('[data-testid="session-timerail-play"]') as HTMLButtonElement).disabled).toBe(false);
222+
expect(container.querySelectorAll('[data-kind="error"]')).toHaveLength(0);
223+
});
224+
225+
it("W2 播放头回路闭环:点时间码 ⇒ 容器 setState ⇒ 播放头 `data-ms` 逐字 = 该段(恰 1 个)", async () => {
226+
const { container } = render(<SessionDetailPanel {...propsOf(detailOf())} />);
227+
fireEvent.click(findButton("三轨对齐"));
228+
await screen.findByTestId("session-tritrack-view");
229+
expect(container.querySelector('[data-testid="session-timerail-playhead"]'), "未请求定位就出现了播放头").toBeNull();
230+
fireEvent.click(container.querySelector('[data-track="transcript"] [data-ms="2500"] button') as HTMLElement);
231+
expect(
232+
[...container.querySelectorAll('[data-testid="session-timerail-playhead"]')].map((el) => el.getAttribute("data-ms")),
233+
).toEqual(["2500"]);
234+
});
235+
});

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,20 @@
2828
* @ai-context: DOM 顺序契约(逐字不变):`SessionDetailHeader` → `SessionQualityCard` →
2929
* `SpeakerSwitchCard` → 切换器行(含 `SessionRefineSection`)→ `refineMsg` 行
3030
* → 视图区 → 两个裁决面板;全部为**同级兄弟**(视图区内的两个新包裹层见宿主头注)。
31+
* @ai-context: **批 6 T25 的容器侧接线(控制方授权的唯一落点)**:`useSessionAudio` 今天**没有别的
32+
* 生产调用点**(宿主 H0 判据要求「宿主自身不 `invoke`」),若本件不接,注入槽
33+
* `audio` 在生产路径上永远是 `undefined` ⇒ 视图侧的「纯注入」无从注入。故本件在此
34+
* ① 调 `useSessionAudio(sessionId)`(**唯一**取数点仍在 `session-detail/useSessionAudio.ts`,
35+
* 本件自身零 `invoke` ⇒ **H0 判据一字不动仍绿**);② 持有播放头状态
36+
* `playheadMs` / `onSeekMs`(槽位文档写明「容器负责 `setState` + 播放头动效」)。
37+
* ⚠️ hook 必须在 `kind === "web"` 早退**之前**调用(hooks 规则)⇒ web 会话也会发一次
38+
* 只读 IPC(该命令对无音频会话返回 `None`,代价已登记)。
39+
* ⚠️ **本件不做 `[[ts:ms]]` 深链**(T26):它只需把 `setPlayheadMs` 接上同一条状态。
3140
*/
3241
import { useEffect, useState } from "react";
3342
import { convertFileSrc } from "@tauri-apps/api/core";
3443
import { useSessionDetailData } from "../hooks/useSessionDetailData";
44+
import { useSessionAudio } from "./session-detail/useSessionAudio";
3545
import SessionDetailHeader from "./session-detail/SessionDetailHeader";
3646
import SessionRawView from "./session-detail/SessionRawView";
3747
import SessionViewHost from "./session-detail/SessionViewHost";
@@ -85,6 +95,10 @@ export default function SessionDetailPanel({ detail, views, fusing, degradedBann
8595
// `SessionAuxPanels`(state 留本件:工具条三按钮在切换器行里,就近持有回调更少暴露面)。
8696
const [showPass2, setShowPass2] = useState(false);
8797
const [showProofread, setShowProofread] = useState(false);
98+
// 批 6 T25(授权①):音频引用(唯一取数在 useSessionAudio)+ 播放头位置。
99+
// `playheadMs` 由视图经 `onSeekMs` 请求(点时间码 / `<audio>` 的 timeupdate 回报)⇒ 这里 setState。
100+
const audio = useSessionAudio(sessionId);
101+
const [playheadMs, setPlayheadMs] = useState<number | null>(null);
88102
// v0.16.1 工作台深链:autoTaskId 到达即切预览视图。深链快照由 hook 持有——App 侧 focus 清空
89103
// 早于本层 effect,直接透传 prop 会在卡片挂载前被置空(竞态);快照 + 会话切换清除保证
90104
// 「只消费一次、不跨会话遗留」。切换 effect 按裁决 D1 留面板(T10 起写入视图记忆)。
@@ -125,6 +139,10 @@ export default function SessionDetailPanel({ detail, views, fusing, degradedBann
125139
onClearToast: clearPanelToast,
126140
autoRefineTaskId: deepTaskId,
127141
onRefineTaskStarted,
142+
// T25:三个 T24 槽位在这里**有值**(audio = hook 产出;playheadMs/onSeekMs = 本件的播放头状态)
143+
audio,
144+
playheadMs,
145+
onSeekMs: setPlayheadMs,
128146
};
129147

130148
return (
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionTriTrackView.rail.test.tsx — T25 在**视图层**的集成判据(时间轨 + 播放头 + 降级)。
4+
*
5+
* @ai-context 与 `SessionTriTrackView.test.tsx` 的分工:那件的 5 条判据(T1–T5,批 5 交付)
6+
* **逐字不动**;本件只加 T25 新增的面 —— ① 时间轨与三轨的**结构关系**(轨在上、轨不在轨里);
7+
* ② 总长真源 `totalMsOf`(派生口径 + `durationMs` 优先);③ 时间码点击 ⇒ 播放头回跳到该段
8+
* (「时间码回跳」的可见动作);④ 注入 `playheadMs` 的**优先级**;⑤ 音频槽在视图侧的落点与
9+
* 无音频时的降级可用性。
10+
* @ai-context **不 mock Tauri**:本件不取数(URL 由注入槽给)⇒ 没有 `@tauri-apps` 的模块边界可
11+
* mock,也没有真播放(判据不点播放控件;媒体侧契约在 `TimeRail.test.tsx`)。
12+
* @ai-context 诚实边界:jsdom 不排版 ⇒ 「播放头看起来在正确位置」不可判,本件只判 `data-ms`
13+
* 与 `data-degraded` 这类**结构事实**;真实播放 / seek 在本环境不可验证(不声称)。
14+
* 副作用:只挂 React 树;不写盘、不发请求、不真播放。
15+
*/
16+
import { cleanup, fireEvent, render } from "@testing-library/react";
17+
import { afterEach, describe, expect, it, vi } from "vitest";
18+
import type { SessionAudioState, SessionDetail } from "../../types/session";
19+
import SessionTriTrackView, { totalMsOf } from "./SessionTriTrackView";
20+
21+
const URL_OK = "asset://localhost/C%3A%2Fdata%2Fsession-audio%2F1042.wav";
22+
const NO_AUDIO: SessionAudioState = { url: null, aligned: false, playable: false, durationMs: null };
23+
const OK: SessionAudioState = { url: URL_OK, aligned: true, playable: true, durationMs: 120_000 };
24+
const ROOT = '[data-testid="session-tritrack-view"]';
25+
const RAIL = '[data-testid="session-timerail"]';
26+
const PLAYHEAD = '[data-testid="session-timerail-playhead"]';
27+
28+
function detailOf(over: Partial<SessionDetail> = {}): SessionDetail {
29+
return {
30+
session: {
31+
id: 1042,
32+
title: "构图与调色",
33+
source_window: "Chrome",
34+
started_at: 1_700_000_000_000,
35+
ended_at: 1_700_003_800_000,
36+
status: "finished",
37+
kind: null,
38+
},
39+
segments: [
40+
{ id: 9001, session_id: 1042, start_ms: 0, end_ms: 2_500, text: "开场:构图三要素", source: "subtitle", confidence: 0.9 },
41+
{ id: 9003, session_id: 1042, start_ms: 9_000, end_ms: 12_000, text: "第二段:色轮", source: "fused", confidence: 0.8 },
42+
{ id: 9002, session_id: 1042, start_ms: 3_725_000, end_ms: 3_728_000, text: "一小时后的复盘", source: "asr", confidence: 0.7 },
43+
],
44+
screens: [
45+
{ session_id: 1042, screen_id: 1, first_seen_ms: 9_000, last_seen_ms: 12_000, title: "调色面板", body: ["色相环"], labels: [], image_ref: "full/a.webp", structure: [] },
46+
],
47+
ocr_blocks: [{ id: 5001, session_id: 1042, timestamp_ms: 500, text: "课程封面", score: 0.81, region: "subtitle" }],
48+
...over,
49+
};
50+
}
51+
52+
interface Over {
53+
readonly audio?: SessionAudioState | null | undefined;
54+
readonly playheadMs?: number | null;
55+
readonly detail?: SessionDetail;
56+
}
57+
function mountView(over: Over = {}) {
58+
const onSeekMs = vi.fn();
59+
const view = render(
60+
<SessionTriTrackView
61+
detail={over.detail ?? detailOf()}
62+
audio={"audio" in over ? over.audio : OK}
63+
playheadMs={over.playheadMs ?? null}
64+
onSeekMs={onSeekMs}
65+
/>,
66+
);
67+
return { ...view, onSeekMs };
68+
}
69+
function q(container: HTMLElement, sel: string): HTMLElement {
70+
const el = container.querySelector(sel);
71+
if (el === null) throw new Error(`没有渲染出 ${sel}`);
72+
return el as HTMLElement;
73+
}
74+
/** 轨条目里的时间码按钮(T25 起时间码是定位按钮 —— 键盘可达由 `Button` 原语保证) */
75+
const codeAt = (container: HTMLElement, ms: number): HTMLElement =>
76+
q(container, `[data-track="transcript"] [data-ms="${ms}"] button`);
77+
78+
/** `totalMsOf` 的三来源小夹具(每个面只动一个来源 ⇒ 变异体的因可归) */
79+
const seg = (end: number): SessionDetail["segments"][number] => ({ id: 1, session_id: 1, start_ms: 0, end_ms: end, text: "t", source: "asr", confidence: 1 });
80+
const scr = (last: number): SessionDetail["screens"][number] => ({ session_id: 1, screen_id: 1, first_seen_ms: 0, last_seen_ms: last, title: null, body: [], labels: [], image_ref: null, structure: [] });
81+
const ocr = (ts: number): SessionDetail["ocr_blocks"][number] => ({ id: 1, session_id: 1, timestamp_ms: ts, text: "o", score: 1, region: "full" });
82+
const parts = (p: { seg?: number[]; scr?: number[]; ocr?: number[] }): SessionDetail =>
83+
detailOf({ segments: (p.seg ?? []).map(seg), screens: (p.scr ?? []).map(scr), ocr_blocks: (p.ocr ?? []).map(ocr) });
84+
const audioWith = (durationMs: number | null): SessionAudioState => ({ url: URL_OK, aligned: true, playable: true, durationMs });
85+
86+
afterEach(cleanup);
87+
88+
describe("I1 结构契约:时间轨在上、三轨不在轨里(既有 T1 的条目锚不变)", () => {
89+
it("轨与本行两兄弟:轨在**前**、`[data-track]` 各 3/1/1 条且都不在轨子树里;轨缺席时三轨照旧", () => {
90+
const { container } = mountView();
91+
const root = q(container, ROOT);
92+
const rail = q(container, RAIL);
93+
const lane = q(container, '[data-track="transcript"]');
94+
expect(rail.compareDocumentPosition(lane) & Node.DOCUMENT_POSITION_FOLLOWING, "时间轨没有排在三条轨之前").not.toBe(0);
95+
expect(rail.contains(lane), "三条轨被塞进了时间轨里(轨就不可共享了)").toBe(false);
96+
expect([...container.querySelectorAll("[data-track]")].map((el) => el.children.length)).toEqual([3, 1, 1]);
97+
expect(root.contains(rail)).toBe(true);
98+
});
99+
});
100+
101+
describe("I2 总长真源 `totalMsOf`:三数组取大,且有 `durationMs` 时以它为准(再对派生值取大)", () => {
102+
it("三个来源各自当过一次最大值;durationMs 覆盖/被兜底/缺省三条支路逐字", () => {
103+
expect([totalMsOf(parts({ seg: [1_000] }), NO_AUDIO), totalMsOf(parts({ scr: [2_000] }), NO_AUDIO), totalMsOf(parts({ ocr: [3_000] }), NO_AUDIO)]).toEqual([1_000, 2_000, 3_000]);
104+
expect(totalMsOf(parts({ seg: [9_000], ocr: [3_000] }), NO_AUDIO)).toBe(9_000);
105+
expect(totalMsOf(parts({}), NO_AUDIO), "三数组全空 ⇒ 0").toBe(0);
106+
// 音频侧:`durationMs` 是音频的真源,但**不许**把派生出来的内容挤出轨外
107+
expect([totalMsOf(parts({ seg: [1_000] }), audioWith(5_000)), totalMsOf(parts({ seg: [1_000] }), audioWith(500)), totalMsOf(parts({ seg: [1_000] }), audioWith(null))]).toEqual([5_000, 1_000, 1_000]);
108+
// 视图把真源用在了轨上:派生 3_728_000 > durationMs 120_000 ⇒ 轨宽 = 3728px(不丢内容)
109+
const { container } = mountView();
110+
expect(q(container, ".ed-timerail__track").style.width).toBe("3728px");
111+
// 反向面:派生值小于 `durationMs` ⇒ 轨宽由音频真源决定(120000ms ⇒ 120px)
112+
cleanup();
113+
const short = mountView({ audio: OK, detail: parts({ seg: [1_000] }) });
114+
expect(q(short.container, ".ed-timerail__track").style.width, "有 durationMs 时没用上它").toBe("120px");
115+
});
116+
});
117+
118+
describe("I3 时间码回跳:点时间码 ⇒ 上行请求**恰一次**且播放头滑到该段", () => {
119+
it("点 9000ms 的时间码 ⇒ onSeekMs(9000) 恰 1 次 + 播放头 data-ms=9000;点另一段 ⇒ 逐字跟随", () => {
120+
const { container, onSeekMs } = mountView({ audio: NO_AUDIO });
121+
fireEvent.click(codeAt(container, 9_000));
122+
expect(onSeekMs.mock.calls.length, "点击没有(或重复)上报").toBe(1);
123+
expect(onSeekMs.mock.calls[0][0]).toBe(9_000);
124+
expect(q(container, PLAYHEAD).dataset.ms, "播放头没有跟到点过的那一段").toBe("9000");
125+
fireEvent.click(codeAt(container, 3_725_000));
126+
expect(container.querySelector(PLAYHEAD)?.getAttribute("data-ms")).toBe("3725000");
127+
// 反例守卫:不点 ⇒ 无播放头(位置不是常量、也不是「第一段」)
128+
cleanup();
129+
const fresh = mountView({ audio: NO_AUDIO });
130+
expect(fresh.container.querySelector(PLAYHEAD)).toBeNull();
131+
expect(fresh.onSeekMs.mock.calls.length).toBe(0);
132+
});
133+
});
134+
135+
describe("I4 注入 `playheadMs` 优先于本地聚焦(T26 深链的先后次序)", () => {
136+
it("注入 52500 后点 9000 的时间码 ⇒ 上行仍收到 9000,但播放头停在注入值", () => {
137+
const { container, onSeekMs } = mountView({ audio: OK, playheadMs: 52_500 });
138+
expect(q(container, PLAYHEAD).dataset.ms).toBe("52500");
139+
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");
142+
});
143+
});
144+
145+
describe("I5 音频槽在视图侧的落点与降级可用性(无音频 ⇒ 只读时间轨)", () => {
146+
it("有音频 ⇒ `<audio src>` = 注入 URL;无音频 ⇒ 轨仍在 + 恰一行 error + 点时间码仍能定位", () => {
147+
const withAudio = mountView({ audio: OK });
148+
expect(q(withAudio.container, "audio").getAttribute("src")).toBe(URL_OK);
149+
expect(withAudio.container.querySelectorAll('[data-kind="error"]')).toHaveLength(0);
150+
cleanup();
151+
152+
const noAudio = mountView({ audio: NO_AUDIO });
153+
const root = q(noAudio.container, RAIL);
154+
expect(root.getAttribute("data-degraded"), "无音频没有进降级态").toBe("true");
155+
expect(noAudio.container.querySelector('[data-testid="session-timerail-viewport"]'), "降级把时间轨带走了").not.toBeNull();
156+
expect(noAudio.container.querySelectorAll('[data-kind="error"]')).toHaveLength(1);
157+
expect(noAudio.container.querySelector("audio"), "无音频却渲染了媒体元素").toBeNull();
158+
// 降级下「时间轨仍可用」的可判部分:点时间码照旧驱动播放头
159+
fireEvent.click(codeAt(noAudio.container, 9_000));
160+
expect(q(noAudio.container, PLAYHEAD).dataset.ms).toBe("9000");
161+
});
162+
});

0 commit comments

Comments
 (0)