Skip to content

Commit 7c1899e

Browse files
committed
feat(session): 加音频引用注入槽与取数钩子
1 parent 1cc3534 commit 7c1899e

6 files changed

Lines changed: 375 additions & 1 deletion

File tree

‎app/src/components/session-detail/SessionViewHost.test.tsx‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1919
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2020
import { useEffect, useRef } from "react";
2121
import type { SessionDetail } from "../../types";
22-
import type { SessionViewSlot, ViewSpec } from "../../views/registry";
22+
import type { SessionAudioState, SessionViewSlot, ViewSpec } from "../../views/registry";
2323
import { FROZEN_VIEW_KEYS, viewsFor } from "../../views/registry";
2424
import { useViewMemory } from "../../views/useViewMemory";
2525

@@ -74,6 +74,19 @@ const HANGING: readonly ViewSpec<SessionViewSlot>[] = [
7474
{ key: "hang", label: "悬挂", icon: "image", appliesTo: "session", load: () => new Promise<never>(() => {}) },
7575
];
7676

77+
/** T24 的过桥探针:把宿主交给视图的**整个槽**记下来(新可选槽是否真到达视图,靠它判) */
78+
const seenSlots: SessionViewSlot[] = [];
79+
const SLOT_PROBE = {
80+
default: (props: SessionViewSlot) => {
81+
seenSlots.push(props);
82+
return <div data-testid="view-slot-probe" />;
83+
},
84+
};
85+
const SLOT_PROBE_VIEWS: readonly ViewSpec<SessionViewSlot>[] = [
86+
STUB_VIEWS[0],
87+
{ key: "slotprobe", label: "槽探针", icon: "clock", appliesTo: "session", load: () => Promise.resolve(SLOT_PROBE) },
88+
];
89+
7790
function detailOf(id = 1042, kind: string | null = null): SessionDetail {
7891
return {
7992
session: { id, title: "化学反应速率", source_window: "Chrome", started_at: 1_700_000_000_000, ended_at: 1_700_000_600_000, status: "finished", kind },
@@ -247,4 +260,34 @@ describe("SessionViewHost · T10 判据(H1–H6 + 默认视图重锚)", () =
247260
render(<HostHarness />);
248261
expect(invokeMock).not.toHaveBeenCalled();
249262
});
263+
264+
it("T24 音频引用可选槽经宿主**逐字**到达非默认视图(过桥的容器侧判据)", async () => {
265+
const audio: SessionAudioState = { url: "http://asset.localhost/x%5C42.wav", aligned: false, playable: false, durationMs: null };
266+
const sought: number[] = [];
267+
const onSeekMs = (ms: number) => sought.push(ms);
268+
seenSlots.length = 0;
269+
render(<HostHarness views={SLOT_PROBE_VIEWS} slot={{ ...slotOf(), audio, playheadMs: 52_500, onSeekMs }} />);
270+
fireEvent.click(seg("槽探针"));
271+
await screen.findByTestId("view-slot-probe");
272+
const got = seenSlots[seenSlots.length - 1];
273+
expect(got.audio, "`audio` 槽没到达视图(或在途中被改写)").toBe(audio);
274+
expect(got.playheadMs).toBe(52_500);
275+
expect(got.onSeekMs).toBe(onSeekMs);
276+
expect(got.detail, "既有 9 槽必须随同一份对象同行(不许因为新槽被复制/重排)").toBe(seenSlots[0].detail);
277+
got.onSeekMs?.(52_500);
278+
expect(sought, "视图 → 容器的回调没有原样到达").toEqual([52_500]);
279+
});
280+
281+
it("T24 可选槽缺省 ⇒ 视图侧读到 `undefined`(可选语义:既有 9 槽夹具一字不改仍可用)", async () => {
282+
seenSlots.length = 0;
283+
render(<HostHarness views={SLOT_PROBE_VIEWS} />);
284+
fireEvent.click(seg("槽探针"));
285+
await screen.findByTestId("view-slot-probe");
286+
const got = seenSlots[seenSlots.length - 1];
287+
expect([got.audio, got.playheadMs, got.onSeekMs], "缺省的新槽不得被宿主补成别的值").toEqual([
288+
undefined,
289+
undefined,
290+
undefined,
291+
]);
292+
});
250293
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
* `nativeButtonBaseline.SPLIT_MOVES` 里 T2 登记的那条搬运的**目标侧计数降到 0**,
3838
* 该守恒判据(`nativeButton.ratchet.test.ts` ④)的处置见 T10 报告(**STOP 项,
3939
* 未擅自改基线**)。
40+
* @ai-context: **批 6 T24 的三个可选槽(`audio` / `playheadMs` / `onSeekMs`)零改动透传**:本件把
41+
* `slot` **整体**交给非默认视图(`createElement(LazyView, slot)`),故新槽随原对象同行
42+
* —— 既不挑选字段、也不在这里取数(T24 的取数在 `session-detail/useSessionAudio.ts`,
43+
* **本件仍零 `invoke`**,H0 判据一字不动)。「过桥」的容器侧判据见
44+
* `SessionViewHost.test.tsx` 的 T24 两条。
4045
* 副作用:无(纯展示 + 只调 `onViewKeyChange`;不读 store、不发请求、不写磁盘、**不 import
4146
* Tauri**)。`lazy` 表由 `useMemo` 按 `views` 引用记忆化 ⇒ 调用点必须传稳定数组。
4247
* 边界:`views` 为空表(G5 的未知 `objectType`)时 `defaultKey` 退化为 `""`,切换器渲染空组、
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* useSessionAudio.test.tsx — 批 6 T24 的**过桥判据面**:音频引用的唯一取数点 + 唯一 URL 构造点。
4+
*
5+
* @ai-context 被测对象 = 容器侧的**桥**:`invoke("session_audio_path")` → `convertFileSrc(path)`
6+
* → `SessionAudioState`。jsdom 里 `convertFileSrc` **不存在**(它是 `window.__TAURI_INTERNALS__`
7+
* 的方法,见 `@tauri-apps/api/core.js` 的 `convertFileSrc` 本体)⇒ **必须注入桩**;桩按 Tauri 的
8+
* **真实形态**实现(`tauri-2.11.5/scripts/core.js:13-20`:Windows + `use_https_scheme: false`
9+
* ⇒ `` http://asset.localhost/${encodeURIComponent(filePath)} ``)⇒ 判据钉在**真实 URL 形态**上,
10+
* 而不是「等于桩自己」的空真。**不连真 Tauri**(只 mock 模块边界)。
11+
* @ai-context 断言一律走 DOM 属性(`data-*`)而不是读组件内部 state ⇒ 判据落在**真实渲染路径**上
12+
* (含「失败不阻断页面」)。**无 jest-dom / 无 user-event** ⇒ 原生 DOM API;**不挂 `performance`**
13+
* (R8.3:jsdom 的 `performance` 自调用会栈溢出)。
14+
* @ai-context 本件**不能**证明什么(诚实边界):① asset 协议下的**真实播放 / seek 不可验证**
15+
* (jsdom 无媒体栈、headless Edge 不说 `asset:` 协议、真机/WebView2 用户已裁决跳过)⇒ 本件
16+
* **不声称**「播放已可用」;② 对齐的**毫秒级精度**不可判(T23 登记的残余 = **块粒度 ±200 ms**);
17+
* ③ 真机返回值(`durationMs` 的真值)不可判 —— 本件只判**契约与透传**。
18+
* ⚠️ 本件不渲染 `<audio>`:播放器归 T25(T24 只交付过桥)。
19+
* 副作用:无(每个用例清 mock;不写盘、不发网络、不连 Tauri)。
20+
*/
21+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
23+
24+
const { invokeMock, convertFileSrcMock } = vi.hoisted(() => ({
25+
invokeMock: vi.fn(),
26+
convertFileSrcMock: vi.fn(),
27+
}));
28+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock, convertFileSrc: convertFileSrcMock }));
29+
30+
import { NO_AUDIO, SESSION_AUDIO_COMMAND, toSessionAudioState, useSessionAudio } from "./useSessionAudio";
31+
import type { SessionAudioRef } from "../../types/sessionAudio";
32+
33+
/** 会话 WAV 的真实路径形态(`{data_dir}/session-audio/{id}.wav`;Windows 反斜杠 + 空格) */
34+
const WAV_A = "C:\\Users\\x\\AppData\\Roaming\\com.entropydecrease.app\\session-audio\\42.wav";
35+
const WAV_B = "C:\\Users\\x\\AppData\\Roaming\\com.entropydecrease.app\\session-audio\\43.wav";
36+
/** Tauri 的真实 asset URL 形态(`scripts/core.js:13-20`)——期望值独立写在断言侧,不借被测模块构造 */
37+
const assetUrl = (p: string): string => `http://asset.localhost/${encodeURIComponent(p)}`;
38+
39+
/** 探针:把 hook 的返回值渲染成属性(`data-pending` = **尚未取到**;`none` = 值为 `null`) */
40+
function Probe({ sessionId }: { sessionId: number }) {
41+
const audio = useSessionAudio(sessionId);
42+
if (audio === undefined) return <div data-testid="page" data-pending="1" />;
43+
return (
44+
<div
45+
data-testid="page"
46+
data-url={audio.url ?? "none"}
47+
data-aligned={String(audio.aligned)}
48+
data-playable={String(audio.playable)}
49+
data-duration={audio.durationMs === null ? "none" : String(audio.durationMs)}
50+
/>
51+
);
52+
}
53+
const attr = (name: string): string | null => screen.getByTestId("page").getAttribute(name);
54+
const pending = (): boolean => attr("data-pending") === "1";
55+
56+
beforeEach(() => {
57+
invokeMock.mockReset();
58+
convertFileSrcMock.mockReset();
59+
// 桩 = Tauri 的真实形态(Windows + use_https_scheme:false)
60+
convertFileSrcMock.mockImplementation((p: string) => assetUrl(p));
61+
});
62+
afterEach(() => cleanup());
63+
64+
describe("T24 · 音频引用过桥(唯一 invoke + convertFileSrc 点)", () => {
65+
it("B1 URL 由 `convertFileSrc` 拼:路径逐字交给它,产物是 asset 形态(裸路径不算 URL)", async () => {
66+
invokeMock.mockResolvedValue({ path: WAV_A, aligned: true, durationMs: 12_345 } satisfies SessionAudioRef);
67+
render(<Probe sessionId={7} />);
68+
await waitFor(() => expect(pending()).toBe(false));
69+
expect(invokeMock, "`session_audio_path` 必须恰调一次(命令名 + 入参逐字)").toHaveBeenCalledTimes(1);
70+
expect(invokeMock).toHaveBeenCalledWith(SESSION_AUDIO_COMMAND, { sessionId: 7 });
71+
expect(convertFileSrcMock, "URL 只能经 convertFileSrc 构造,且入参 = 后端给的路径").toHaveBeenCalledTimes(1);
72+
expect(convertFileSrcMock).toHaveBeenCalledWith(WAV_A);
73+
expect(attr("data-url")).toBe(assetUrl(WAV_A));
74+
expect(attr("data-url")?.startsWith("http://asset.localhost/")).toBe(true);
75+
expect(attr("data-url"), "裸路径被当成 URL 用了").not.toBe(WAV_A);
76+
expect(attr("data-url"), "分隔符没被百分号编码 ⇒ 不是 asset URL 形态").not.toContain("\\");
77+
});
78+
79+
it("B2 失败静默降级:`invoke` 抛 ⇒ 四字段降级对象 + 页面仍渲染(不阻断)", async () => {
80+
invokeMock.mockRejectedValue(new Error("session_audio_path 失败:目录不可读"));
81+
render(<Probe sessionId={7} />);
82+
await waitFor(() => expect(pending()).toBe(false));
83+
expect(screen.getByTestId("page"), "降级不得让页面消失").not.toBeNull();
84+
expect([attr("data-url"), attr("data-aligned"), attr("data-playable"), attr("data-duration")])
85+
.toEqual(["none", "false", "false", "none"]);
86+
expect(convertFileSrcMock, "没有路径 ⇒ 不得构造 URL").not.toHaveBeenCalled();
87+
expect(JSON.stringify(NO_AUDIO), "降级常量的键集/顺序是冻结快照").toBe(
88+
'{"url":null,"aligned":false,"playable":false,"durationMs":null}',
89+
);
90+
});
91+
92+
it("B3 `aligned` / `durationMs` 如实透传、`playable` 由 `durationMs` 派生(不恒真、不许吞掉 0)", async () => {
93+
invokeMock.mockResolvedValue({ path: WAV_A, aligned: false, durationMs: null } satisfies SessionAudioRef);
94+
const first = render(<Probe sessionId={7} />);
95+
await waitFor(() => expect(pending()).toBe(false));
96+
expect([attr("data-aligned"), attr("data-duration"), attr("data-playable")]).toEqual(["false", "none", "false"]);
97+
first.unmount();
98+
invokeMock.mockReset();
99+
// `durationMs: 0` = 整数除法的亚毫秒边界(data 长度 1–31 字节)⇒ 必须**逐字**透传(`|| null` 会吞掉它)
100+
invokeMock.mockResolvedValue({ path: WAV_A, aligned: true, durationMs: 0 } satisfies SessionAudioRef);
101+
render(<Probe sessionId={7} />);
102+
await waitFor(() => expect(pending()).toBe(false));
103+
expect([attr("data-aligned"), attr("data-duration"), attr("data-playable")]).toEqual(["true", "0", "true"]);
104+
});
105+
106+
it("B4 无音频(`Ok(None)`)⇒ 与失败**同形**的降级对象(`null` ≠ 尚未取到)", async () => {
107+
invokeMock.mockResolvedValue(null);
108+
render(<Probe sessionId={7} />);
109+
await waitFor(() => expect(pending()).toBe(false));
110+
expect([attr("data-url"), attr("data-aligned"), attr("data-playable"), attr("data-duration")])
111+
.toEqual(["none", "false", "false", "none"]);
112+
expect(convertFileSrcMock).not.toHaveBeenCalled();
113+
});
114+
115+
it("B5 幂等与新会话重取:同 `sessionId` 不重取;换 `sessionId` 先复位为「尚未取到」再取新值", async () => {
116+
invokeMock.mockImplementation((_cmd: string, args: { sessionId: number }) =>
117+
Promise.resolve(
118+
args.sessionId === 7
119+
? ({ path: WAV_A, aligned: true, durationMs: 1_000 } satisfies SessionAudioRef)
120+
: ({ path: WAV_B, aligned: true, durationMs: 2_000 } satisfies SessionAudioRef),
121+
),
122+
);
123+
const view = render(<Probe sessionId={7} />);
124+
await waitFor(() => expect(attr("data-url")).toBe(assetUrl(WAV_A)));
125+
expect(invokeMock).toHaveBeenCalledTimes(1);
126+
view.rerender(<Probe sessionId={7} />);
127+
expect(invokeMock, "`sessionId` 不变却重取(幂等破功)").toHaveBeenCalledTimes(1);
128+
expect(attr("data-url")).toBe(assetUrl(WAV_A));
129+
view.rerender(<Probe sessionId={8} />);
130+
expect(pending(), "换会话的瞬间必须复位为「尚未取到」(不许拿上一会话的引用冒充)").toBe(true);
131+
await waitFor(() => expect(attr("data-url")).toBe(assetUrl(WAV_B)));
132+
expect(invokeMock).toHaveBeenCalledTimes(2);
133+
expect(invokeMock).toHaveBeenLastCalledWith(SESSION_AUDIO_COMMAND, { sessionId: 8 });
134+
});
135+
136+
it("B6 纯映射的边界:`null` ⇒ 降级常量;注入的 `toUrl` 决定 URL(生产默认 = 真的 `convertFileSrc`)", () => {
137+
expect(toSessionAudioState(null)).toBe(NO_AUDIO);
138+
expect(toSessionAudioState({ path: WAV_A, aligned: true, durationMs: null }, (p) => `u:${p}`)).toEqual({
139+
url: `u:${WAV_A}`,
140+
aligned: true,
141+
playable: false,
142+
durationMs: null,
143+
});
144+
expect(toSessionAudioState({ path: WAV_A, aligned: false, durationMs: 3 }).url).toBe(assetUrl(WAV_A));
145+
});
146+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* useSessionAudio — 会话音频引用的**唯一过桥点**(批 6 T24 · R5.5 / R5.5-b)。
3+
*
4+
* @ai-context 为什么必须住在 `components/**`:`views/**` 有一条**整目录**硬边界
5+
* (`views/architecture.guard.test.ts` 的 A3③:生产文件**含 `import type` 在内**零 Tauri 边),
6+
* 而播放头所需的两样东西 —— `invoke("session_audio_path")` 与 `convertFileSrc` —— 都在 Tauri 面上
7+
* ⇒ 取数与 URL 拼接**只能**在容器侧发生,视图侧只收注入的 `SessionViewSlot.audio`
8+
* (依赖方向 §7.1:领域 → 视图 → 容器 → 原语,**禁止反向**)。本件是 `app/src/**` 里
9+
* `session_audio_path` 的**唯一**调用点。
10+
* @ai-context **命令只给路径**(T22 的出参 `{path, aligned, durationMs}`):URL 一律由前端
11+
* `convertFileSrc` 拼,Windows 形态逐字 = Tauri `scripts/core.js:13-20` 的
12+
* `` http://asset.localhost/${encodeURIComponent(绝对路径)} ``(`use_https_scheme: false`)。
13+
* ⚠️ **禁止 `fetch()` 取音频**(R5.5-b 约束 2:不带 `Range` 的 200 分支会 `read_to_end`
14+
* 整段 WAV —— 约 115 MB/小时 —— 进内存)⇒ 唯一合法消费形态是 `<audio src>`(T25 落地)。
15+
* @ai-context **三个字段一律如实透传,不许改写成「看起来更好」的值**:`aligned` = T23 的对齐簿记
16+
* (无 sidecar / 历史录音 ⇒ `false`,语义 =「**不能保证**对齐」);`durationMs === null`
17+
* = 未 finalize ⇒ `playable === false`(R5.5-b 约束 3:WAV 头的 `data` 长度创建时写 0,
18+
* `finalize()` 是唯一回填点)⇒ 录制中必须禁用播放并如实提示(一行 `StatusLine`,**不是** toast)。
19+
* @ai-context **适用范围(硬边界)**:**只有实时采集会话有音频**(导入会话的音轨在 `%TEMP%` 且
20+
* 导入结束即删、不在 asset scope)⇒ 本件的二分只有「有音频 / 无音频」(`Ok(None)` ⇒ 降级对象),
21+
* **不猜来源**、不引入新的会话类型判据。
22+
* @ai-context **诚实边界(不得越界声称)**:① asset 协议下的**真实播放 / seek 在本环境不可验证**
23+
* (jsdom 无媒体栈、headless Edge 不说 `asset:` 协议、真机/WebView2 用户已裁决跳过)⇒ 本件
24+
* **不声称**「播放已可用」;② 对齐精度残余 = **块粒度 ±200 ms**(T23 登记的硬边界;sidecar 未加
25+
* `firstSampleMs`/块长键 ⇒ **毫秒级定位需要新契约**)⇒ 本件不提供任何「精确到毫秒」的换算。
26+
* 副作用:一次只读 IPC(`session_audio_path`)+ 一次纯字符串拼接;失败**静默降级**、不阻断页面。
27+
* 边界:`sessionId` 不变不重取(`useEffect` deps `[sessionId]`);卸载后到达的响应被丢弃
28+
* (`alive` 闸)⇒ 不对已卸载组件 `setState`。
29+
*/
30+
import { useEffect, useState } from "react";
31+
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
32+
import type { SessionAudioRef, SessionAudioState } from "../../types/sessionAudio";
33+
34+
/** IPC 命令名(T22 交付;`check-command-registry` 计数 313/313 里的那一条)。 */
35+
export const SESSION_AUDIO_COMMAND = "session_audio_path";
36+
37+
/** 无音频 / 取数失败的**统一降级态**(`Ok(None)` 与 `invoke` 抛错**同形** —— R5.5-b 约束③)。 */
38+
export const NO_AUDIO: SessionAudioState = Object.freeze({
39+
url: null,
40+
aligned: false,
41+
playable: false,
42+
durationMs: null,
43+
});
44+
45+
/**
46+
* 纯映射:IPC 出参 → 视图槽的音频引用(**唯一** URL 构造点)。
47+
*
48+
* @ai-context Why 独立成纯函数:URL 构造必须**可单测**,而 jsdom 里 `convertFileSrc` 不存在
49+
* (它是 `window.__TAURI_INTERNALS__` 的方法)⇒ 目标构造函数由调用方注入,生产默认值就是真的
50+
* `convertFileSrc`(默认参数在**调用时**求值,不是模块加载时)。
51+
* `playable` **由 `durationMs` 派生**(T22 的 `None` 判据),不新增第二路真源。
52+
* 边界:`durationMs` **逐字透传**(含 `0` —— 整数除法的亚毫秒边界,不许用 `|| null` 吞掉);
53+
* `aligned` 同(**不许**恒真、不许取反)。
54+
*/
55+
export function toSessionAudioState(
56+
ref: SessionAudioRef | null,
57+
toUrl: (path: string) => string = convertFileSrc,
58+
): SessionAudioState {
59+
if (ref === null) return NO_AUDIO;
60+
return {
61+
url: toUrl(ref.path),
62+
aligned: ref.aligned,
63+
playable: ref.durationMs !== null,
64+
durationMs: ref.durationMs,
65+
};
66+
}
67+
68+
/**
69+
* 取会话音频引用(**唯一** `invoke` 点)。
70+
*
71+
* @ai-context 返回值三态与槽位类型逐字对应:`undefined` = **尚未取到**(挂载后、以及换会话的瞬间
72+
* 先复位);`SessionAudioState` = 已取到(其中 `url === null` 即「无音频」,含失败降级)。
73+
* `invoke` 抛 ⇒ 降级对象 + **不阻断页面**(与 `audio_store.rs` 的「落盘失败不阻断会话主链路」
74+
* 同向)。⚠️ 本 hook **尚无生产调用点**:接线归 T25(时间轨/播放头)/ T26(`[[ts:ms]]` 深链)——
75+
* 槽位与类型已冻结,见 T24 报告的「有意的两步」。
76+
*/
77+
export function useSessionAudio(sessionId: number): SessionAudioState | undefined {
78+
const [audio, setAudio] = useState<SessionAudioState | undefined>(undefined);
79+
useEffect(() => {
80+
let alive = true;
81+
// 换会话 ⇒ 先回到「尚未取到」:否则上一会话的音频引用会在新会话的视图里短暂冒充有效值
82+
setAudio(undefined);
83+
invoke<SessionAudioRef | null>(SESSION_AUDIO_COMMAND, { sessionId })
84+
.then((ref) => {
85+
if (alive) setAudio(toSessionAudioState(ref));
86+
})
87+
.catch(() => {
88+
if (alive) setAudio(NO_AUDIO);
89+
});
90+
return () => {
91+
alive = false;
92+
};
93+
}, [sessionId]);
94+
return audio;
95+
}

0 commit comments

Comments
 (0)