Skip to content

Commit 7df0d48

Browse files
committed
feat(ui): 音频存储面板的开关读写真实配置
1 parent bb08076 commit 7df0d48

2 files changed

Lines changed: 188 additions & 1 deletion

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* AudioStoragePanel.test.tsx — 批 8 T27 的**过桥判据面**:落盘开关真的读写后端配置。
4+
*
5+
* @ai-context 被测对象 = 面板的开关桥:`invoke(session_audio_config_set, { enabled })` →
6+
* **用后端回读的状态**刷 UI。jsdom 里没有真 Tauri ⇒ 只 mock 模块边界(同
7+
* `session-detail/useSessionAudio.test.tsx` 先例),**不连真 IPC、不写盘、不连库**。
8+
* @ai-context 断言走 DOM(`data-testid` / 文案)而不是组件内部 state ⇒ 判据落在真实渲染路径上
9+
* (含「失败不假装成功」)。**无 jest-dom / 无 user-event** ⇒ 原生 DOM API + `act` + `waitFor`。
10+
* @ai-context ⚠️ 诚实边界(真机不可达,用户裁决 U5 跳过):① 本件**不声称**「跨重启保持」——
11+
* 持久化判据在 Rust 侧(`audio_store_config::tests::user_toggle_persists_disabled`,
12+
* 机器代替品 = 重新 `load` 同一文件);② 「死分支复活」(`enabled === false` 的提示条)
13+
* **只在 jsdom 层可证**;③ 真机 WebView2 的按钮交互与 env 覆盖显示未验证。
14+
* @ai-context 结构判据:开关必须是 `Button` 原语(类 `ed-btn`),且面板内 `<button>` 总数
15+
* **恰 2**(= 原语开关 + 既有的原生「立即清理」)——多一个原生按钮会让
16+
* `nativeButtonBaseline` 的逐文件键 1 → 2(棘轮红)。
17+
*/
18+
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
19+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20+
21+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
22+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
23+
24+
import AudioStoragePanel, { SET_ENABLED_COMMAND } from "./AudioStoragePanel";
25+
26+
/** 后端状态载荷(线格式 camelCase;字段与 `commands_audio::SessionAudioStatus` 同集) */
27+
function statusOf(enabled: boolean, effective = enabled, over: Record<string, number> = {}) {
28+
return {
29+
fileCount: 3,
30+
totalBytes: 2048,
31+
retentionDays: 30,
32+
diskBudgetBytes: 4_294_967_296,
33+
enabled,
34+
effective,
35+
...over,
36+
};
37+
}
38+
39+
/** 「未启用」提示条的可判文本(前端 `enabled === false` 分支) */
40+
const HINT_OFF = "音频落盘未启用——会话原始音频不会保存";
41+
/** 开关文案(`enabled === false` ⇒ 「落盘:关」) */
42+
const LABEL_ON = "落盘:开";
43+
const LABEL_OFF = "落盘:关";
44+
45+
const toggle = (): HTMLElement => screen.getByTestId("audio-store-toggle");
46+
const hasText = (needle: string): boolean => (document.body.textContent ?? "").includes(needle);
47+
const bodyText = (): string => document.body.textContent ?? "";
48+
49+
beforeEach(() => {
50+
invokeMock.mockReset();
51+
});
52+
afterEach(() => cleanup());
53+
54+
describe("T27 · 音频落盘开关(读写真实配置通道)", () => {
55+
it("S1 状态来自 `session_audio_status`:`enabled=false` 时「未启用」提示条**真的出现**(死分支复活)", async () => {
56+
invokeMock.mockResolvedValue(statusOf(false));
57+
render(<AudioStoragePanel />);
58+
// Act:等首帧取数落地(提示条由 `!status.enabled` 渲染)
59+
await waitFor(() => expect(hasText(LABEL_OFF)).toBe(true));
60+
// Assert:提示条在 → 这条分支今天可达(T27 前 `enabled` 恒 true ⇒ 永不渲染)
61+
expect(hasText(HINT_OFF), "enabled=false 时未启用提示条必须出现").toBe(true);
62+
expect(bodyText()).toContain("3 个文件");
63+
expect(invokeMock).toHaveBeenCalledWith("session_audio_status");
64+
});
65+
66+
it("S2 提示条是**条件**渲染:`enabled=true` 时不得出现(反例守卫)", async () => {
67+
invokeMock.mockResolvedValue(statusOf(true));
68+
render(<AudioStoragePanel />);
69+
await waitFor(() => expect(hasText(LABEL_ON)).toBe(true));
70+
expect(hasText(HINT_OFF), "enabled=true 却显示了未启用提示").toBe(false);
71+
});
72+
73+
it("S3 点击开关 ⇒ `session_audio_config_set` 恰一次且入参为 `{ enabled }`;UI 用**后端回读值**更新", async () => {
74+
invokeMock.mockImplementation((cmd: string) =>
75+
Promise.resolve(
76+
cmd === SET_ENABLED_COMMAND ? statusOf(false) : statusOf(true),
77+
),
78+
);
79+
render(<AudioStoragePanel />);
80+
await waitFor(() => expect(hasText(LABEL_ON)).toBe(true));
81+
// Act
82+
await act(async () => {
83+
toggle().click();
84+
});
85+
// Assert:命令名 + 入参逐字;UI 已按**返回值**(enabled=false)翻转
86+
expect(invokeMock).toHaveBeenCalledWith(SET_ENABLED_COMMAND, { enabled: false });
87+
expect(invokeMock.mock.calls.filter(([c]) => c === SET_ENABLED_COMMAND)).toHaveLength(1);
88+
expect(hasText(LABEL_OFF), "开关文案未按后端返回值更新").toBe(true);
89+
expect(hasText(HINT_OFF), "后端回报关闭后,提示条必须出现").toBe(true);
90+
});
91+
92+
it("S4 入参 = **当前值的反**(连点两次 ⇒ false 再 true),不做本地乐观翻转", async () => {
93+
invokeMock.mockImplementation((cmd: string, args: { enabled: boolean }) =>
94+
Promise.resolve(cmd === SET_ENABLED_COMMAND ? statusOf(args.enabled) : statusOf(true)),
95+
);
96+
render(<AudioStoragePanel />);
97+
await waitFor(() => expect(hasText(LABEL_ON)).toBe(true));
98+
await act(async () => {
99+
toggle().click();
100+
});
101+
await act(async () => {
102+
toggle().click();
103+
});
104+
const sent = invokeMock.mock.calls.filter(([c]) => c === SET_ENABLED_COMMAND).map(([, a]) => a);
105+
expect(sent).toEqual([{ enabled: false }, { enabled: true }]);
106+
});
107+
108+
it("S5 写盘失败 ⇒ 走既有错误行体例,且**不假装成功**(文案与提示条保持原状)", async () => {
109+
invokeMock.mockImplementation((cmd: string) =>
110+
cmd === SET_ENABLED_COMMAND
111+
? Promise.reject(new Error("磁盘只读"))
112+
: Promise.resolve(statusOf(true)),
113+
);
114+
render(<AudioStoragePanel />);
115+
await waitFor(() => expect(hasText(LABEL_ON)).toBe(true));
116+
// Act
117+
await act(async () => {
118+
toggle().click();
119+
});
120+
// Assert:入口错误行 + 未启用提示条都**不得**出现(成功态未被伪造)
121+
expect(hasText("音频落盘开关保存失败")).toBe(true);
122+
expect(bodyText()).toContain("磁盘只读");
123+
expect(hasText(LABEL_OFF), "失败却把开关翻成了关(本地乐观值)").toBe(false);
124+
expect(hasText(HINT_OFF), "失败却显示了未启用提示条").toBe(false);
125+
});
126+
127+
it("S6 结构判据:开关是 `Button` 原语(类 `ed-btn`),面板内 `<button>` 恰 2 个", async () => {
128+
invokeMock.mockResolvedValue(statusOf(true));
129+
const { container } = render(<AudioStoragePanel />);
130+
await waitFor(() => expect(hasText(LABEL_ON)).toBe(true));
131+
expect(toggle().tagName).toBe("BUTTON");
132+
expect(toggle().className, "开关不是 L1 `Button` 原语(缺 ed-btn 类)").toContain("ed-btn");
133+
// 2 = 原语开关 + 既有的原生「立即清理」;多一个原生按钮 ⇒ nativeButton 棘轮逐文件键 1 → 2
134+
expect(container.querySelectorAll("button")).toHaveLength(2);
135+
expect(container.querySelectorAll("button.ed-btn")).toHaveLength(1);
136+
});
137+
});

‎app/src/components/AudioStoragePanel.tsx‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,28 @@
55
* @ai-context: 展示音频落盘状态(文件数/占用/保留期/预算)+ 手动清理入口
66
* (超保留期删旧 + 超预算删最旧);enabled=false 时提示未启用
77
* (前端可见化——此前仅后端注释承诺)。
8+
* @ai-context: 批 8 T27(用户裁决 U1-a #1):加**真开关** —— 此前后端没有 setter 命令、
9+
* 配置也没有落盘通道 ⇒ `status.enabled` 恒 true、用户改不了(产品问题 #1)。
10+
* 现在点击 ⇒ `session_audio_config_set({ enabled })` ⇒ **用后端回读的状态整体
11+
* 替换本地状态**(不写本地乐观值:写盘失败必须从错误行走出来,不许假装成功)。
12+
* @ai-context: 开关用 `Button` 原语;本文件既有的「立即清理」是原生 `<button>`(在
13+
* `nativeButtonBaseline` 里登记为 1)⇒ **那个不动**,新开关不得再添原生按钮。
14+
* @ai-context: 写盘位置 = 应用数据目录的 `audio-store.json`(后端构造,前端不传路径);
15+
* 开关**下次实时会话**生效(不热切换进行中会话)。
816
*/
917
import { useCallback, useEffect, useState } from "react";
1018
import { invoke } from "@tauri-apps/api/core";
1119

20+
import { Button } from "../ui/primitives";
21+
1222
interface SessionAudioStatus {
1323
fileCount: number;
1424
totalBytes: number;
1525
retentionDays: number;
1626
diskBudgetBytes: number;
1727
enabled: boolean;
28+
/** 生效开关(env ENTROPY_AUDIO_STORE 覆盖配置文件时与 `enabled` 不同) */
29+
effective: boolean;
1830
}
1931

2032
interface CleanupSummary {
@@ -30,11 +42,15 @@ function fmtBytes(b: number): string {
3042
return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`;
3143
}
3244

45+
/** 设置开关的 IPC 名(与后端 `commands_audio::session_audio_config_set` 逐字同名)。 */
46+
export const SET_ENABLED_COMMAND = "session_audio_config_set";
47+
3348
export default function AudioStoragePanel() {
3449
const [status, setStatus] = useState<SessionAudioStatus | null>(null);
3550
const [error, setError] = useState("");
3651
const [info, setInfo] = useState("");
3752
const [cleaning, setCleaning] = useState(false);
53+
const [saving, setSaving] = useState(false);
3854

3955
const refresh = useCallback(async () => {
4056
setError("");
@@ -64,6 +80,26 @@ export default function AudioStoragePanel() {
6480
}
6581
};
6682

83+
/**
84+
* 切换落盘开关(持久化;下次实时会话生效)。
85+
*
86+
* @ai-context: 入参 = **当前值的反**(不在本地先翻转)⇒ 连点不会让 UI 与后端错开;
87+
* 成功路径只用后端回读的 status(`setStatus(await invoke(...))`)。
88+
*/
89+
const toggle = async (next: boolean) => {
90+
setSaving(true);
91+
setError("");
92+
setInfo("");
93+
try {
94+
setStatus(await invoke<SessionAudioStatus>(SET_ENABLED_COMMAND, { enabled: next }));
95+
setInfo(next ? "已开启:原始音频将在下次实时会话落盘" : "已关闭:下次实时会话不再保存原始音频");
96+
} catch (e) {
97+
setError(`音频落盘开关保存失败: ${e}`);
98+
} finally {
99+
setSaving(false);
100+
}
101+
};
102+
67103
return (
68104
<div style={{ marginTop: 10 }}>
69105
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
@@ -73,6 +109,18 @@ export default function AudioStoragePanel() {
73109
{status.fileCount} 个文件 · {fmtBytes(status.totalBytes)} · 保留 {status.retentionDays} 天
74110
</span>
75111
)}
112+
<Button
113+
variant="secondary"
114+
size="sm"
115+
busy={saving}
116+
disabled={!status}
117+
title="开启 / 关闭会话音频落盘(写入应用数据目录 audio-store.json;下次实时会话生效)"
118+
testId="audio-store-toggle"
119+
style={{ marginLeft: "auto" }}
120+
onClick={() => void toggle(!(status?.enabled ?? true))}
121+
>
122+
{status?.enabled === false ? "落盘:关" : "落盘:开"}
123+
</Button>
76124
<button
77125
onClick={() => void cleanup()}
78126
disabled={cleaning || !status?.enabled}
@@ -86,7 +134,9 @@ export default function AudioStoragePanel() {
86134
{!error && info && <div style={{ fontSize: 11, color: "#0f766e" }}>{info}</div>}
87135
{status && !status.enabled && (
88136
<div style={{ fontSize: 11, color: "#b45309", background: "#fffbeb", border: "1px solid #fcd34d", borderRadius: 6, padding: "4px 8px" }}>
89-
音频落盘未启用——会话原始音频不会保存(讲者分析等离线能力将不可用)
137+
{status.effective
138+
? "音频落盘已关闭,但环境变量 ENTROPY_AUDIO_STORE 正在覆盖——仍会保存原始音频"
139+
: "音频落盘未启用——会话原始音频不会保存(讲者分析等离线能力将不可用)"}
90140
</div>
91141
)}
92142
</div>

0 commit comments

Comments
 (0)