Skip to content

Commit 47e2b56

Browse files
committed
test(session): 覆盖音频删除的失败与部分删除边界
1 parent b0508d9 commit 47e2b56

2 files changed

Lines changed: 253 additions & 0 deletions

File tree

‎app/src-tauri/src/session_audio_purge.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,8 @@ pub fn purge_session_audio(data_dir: &Path, ids: &[i64]) -> AudioPurgeSummary {
110110
}
111111
summary
112112
}
113+
114+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
115+
#[cfg(test)]
116+
#[path = "session_audio_purge_tests.rs"]
117+
mod tests;
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
//! 删除会话的音频级联清理单测(批 8 T28;内存库 + 临时目录,**不依赖真实模型/真实音频**)。
2+
//!
3+
//! @ai-context: 判据面 = ① 正常删除 ⇒ 音频与 sidecar **都不存在**(隐私面,本任务核心)
4+
//! ② 音频不存在 ⇒ **不报错**(幂等)③ 删除失败 ⇒ 会话记录**仍被删掉** +
5+
//! 失败**被上报** ④ 部分删除(多文件里一件失败)⇒ 终态**可解释**
6+
//! ⑤ 与落盘开关无关 ⑥ DB 失败 ⇒ 文件一个不动(先 DB 后文件的顺序守卫)。
7+
//! @ai-context: 副作用 = 只在 `%TEMP%/entropy-t28-*` 建/删夹具 + `:memory:` 库;**不**读真实
8+
//! `%APPDATA%`、**不**写仓库内任何路径(用完即删)。
9+
//! @ai-context: 被测对象 = `purge_session_audio` / `session_audio_paths`(纯文件面)+ **两条**
10+
//! 删除路径的命令体 `commands_session::delete_session_in` 与
11+
//! `commands_session_delete::run_batch_delete_with_audio`
12+
//! (`#[tauri::command]` 是薄壳:`State<'_, AppState>` 单测里造不出——同 T22 的
13+
//! `session_audio_ref_in` 口径)。
14+
//! @ai-context: 「删除失败」的造法 = 用**同名目录**占位(`remove_file` 对目录必失败:
15+
//! Windows `ERROR_ACCESS_DENIED` / Unix `EISDIR`,两平台都**不是** `NotFound`)
16+
//! ——跨平台确定性的**真实** OS 失败源;**不用** `set_readonly`:Unix 下 unlink
17+
//! 只受父目录权限约束,只读文件照样删得掉 ⇒ 用例会**假绿**。
18+
19+
use super::*;
20+
use crate::commands_session::delete_session_in;
21+
use crate::commands_session_delete::run_batch_delete_with_audio;
22+
use crate::db::Db;
23+
use crate::types::NewSession;
24+
25+
/// 独立临时 `data_dir` 夹具(pid + tag ⇒ 并发用例互不踩;Drop 时整体删除)。
26+
struct TempDataDir {
27+
/// 充当 `AppState::data_dir` 的根(`session-audio/` 的父目录)
28+
root: std::path::PathBuf,
29+
/// 会话音频目录(`{root}/session-audio`)
30+
base: std::path::PathBuf,
31+
}
32+
33+
impl TempDataDir {
34+
fn new(tag: &str) -> Self {
35+
let root = std::env::temp_dir().join(format!("entropy-t28-{}-{}", std::process::id(), tag));
36+
let base = root.join(SESSION_AUDIO_DIR);
37+
std::fs::create_dir_all(&base).expect("建临时会话音频目录");
38+
Self { root, base }
39+
}
40+
41+
/// 写夹具音频两件(内容无关紧要:本模块只判存在性 ⇒ 不依赖真实音频/模型)。
42+
fn write_audio(&self, session_id: i64) {
43+
std::fs::write(self.base.join(format!("{}.wav", session_id)), b"RIFF-fixture")
44+
.expect("写夹具 WAV");
45+
std::fs::write(self.base.join(format!("{}.wav.meta.json", session_id)), SIDECAR)
46+
.expect("写夹具 sidecar");
47+
}
48+
49+
/// 造「删不掉」的音频:`{id}.wav` 位置放**目录**(sidecar 照常写)。
50+
fn block_wav(&self, session_id: i64) -> std::path::PathBuf {
51+
let blocked = self.base.join(format!("{}.wav", session_id));
52+
std::fs::create_dir_all(&blocked).expect("建占位目录");
53+
std::fs::write(self.base.join(format!("{}.wav.meta.json", session_id)), SIDECAR)
54+
.expect("写夹具 sidecar");
55+
blocked
56+
}
57+
58+
fn root(&self) -> &std::path::Path {
59+
&self.root
60+
}
61+
}
62+
63+
impl Drop for TempDataDir {
64+
fn drop(&mut self) {
65+
let _ = std::fs::remove_dir_all(&self.root);
66+
}
67+
}
68+
69+
/// 夹具 sidecar 内容(键名照 T23 冻结快照,只求可辨识)。
70+
const SIDECAR: &[u8] = br#"{"version":1,"aligned":true,"firstTsMs":0,"samplesWritten":16000}"#;
71+
72+
fn mem_db() -> Db {
73+
Db::open(":memory:").expect("open in-memory db")
74+
}
75+
76+
/// 建一个会话(内存库)。
77+
fn session(db: &Db, title: &str) -> i64 {
78+
db.create_session(&NewSession {
79+
title: title.into(),
80+
source_window: None,
81+
profile: None,
82+
kind: None,
83+
})
84+
.expect("create session")
85+
.id
86+
}
87+
88+
#[test]
89+
fn delete_session_removes_audio_and_sidecar() {
90+
// Arrange:会话 + 已落盘的音频两件
91+
let db = mem_db();
92+
let dir = TempDataDir::new("v1-single");
93+
let id = session(&db, "有录音的课");
94+
dir.write_audio(id);
95+
let (wav, sidecar) = session_audio_paths(&dir.base, id);
96+
assert!(wav.exists() && sidecar.exists(), "夹具前置:音频与 sidecar 应已落盘");
97+
// Act:删会话(单条路径)
98+
let ok = delete_session_in(&db, dir.root(), id).expect("删会话不应报错");
99+
// Assert:会话记录消失 + 音频两件都不存在(隐私面)
100+
assert!(ok, "会话应被删除");
101+
assert!(db.get_session(id).expect("db").is_none(), "会话记录必须消失");
102+
assert!(!wav.exists(), "删会话后音频必须不存在: {}", wav.display());
103+
assert!(!sidecar.exists(), "删会话后 sidecar 必须不存在: {}", sidecar.display());
104+
}
105+
106+
#[test]
107+
fn purge_is_idempotent_when_audio_absent() {
108+
// Arrange:会话没有音频,连 session-audio/ 目录都不存在
109+
let db = mem_db();
110+
let id = session(&db, "无录音的课");
111+
let bare = std::env::temp_dir().join(format!("entropy-t28-bare-{}", std::process::id()));
112+
// Act:清理面 + 删会话本体各走一次
113+
let summary = purge_session_audio(&bare, &[id]);
114+
let ok = delete_session_in(&db, &bare, id).expect("音频不存在时必须不报错");
115+
// Assert:幂等——不报错、不计失败(`absent` 显式计数,不静默)
116+
assert_eq!(summary.deleted, 0);
117+
assert_eq!(summary.absent, 2, "两件文件都本就不存在,应显式计 absent");
118+
assert_eq!(summary.failed(), 0, "音频不存在不得被记为失败");
119+
assert!(summary.is_clean(), "无失败才是干净终态");
120+
assert!(ok, "删会话本身照常成功");
121+
assert!(db.get_session(id).expect("db").is_none());
122+
}
123+
124+
#[test]
125+
fn audio_delete_failure_must_be_reported() {
126+
// Arrange:同名目录占位 ⇒ remove_file 必失败(真实 OS 错误,不是 mock)
127+
let db = mem_db();
128+
let dir = TempDataDir::new("v2-fail");
129+
let id = session(&db, "删不掉的课");
130+
let blocked = dir.block_wav(id);
131+
// Act ①:清理面本身
132+
let summary = purge_session_audio(dir.root(), &[id]);
133+
// Assert ①:失败被上报(计数 + 明细点名那件文件)
134+
assert_eq!(summary.failed(), 1, "失败必须被上报(计数)");
135+
assert_eq!(summary.deleted, 1, "B 的 sidecar 应被删掉;被占位的那件不得记成已删");
136+
assert!(!summary.is_clean(), "有失败就不是干净终态");
137+
assert!(
138+
summary.failures[0].contains(&format!("{}.wav", id)),
139+
"失败必须被上报(明细须点名文件): {:?}",
140+
summary.failures
141+
);
142+
assert!(blocked.exists(), "占位目录仍在 ⇒ 失败是真的(不是夹具假红)");
143+
// Act ②:删会话本体
144+
let ok = delete_session_in(&db, dir.root(), id).expect("文件删不掉不得让删会话报错");
145+
// Assert ②:会话记录**仍被删掉**(「部分删除」是允许的终态,不回滚 DB)
146+
assert!(ok, "会话记录必须被删掉");
147+
assert!(db.get_session(id).expect("db").is_none(), "文件失败不得回滚会话删除");
148+
}
149+
150+
#[test]
151+
fn partial_delete_terminal_state_is_explainable() {
152+
// Arrange:A 正常两件;B 的 wav 被占位、sidecar 正常 ⇒ 四件里三删一失败
153+
let dir = TempDataDir::new("v2-partial");
154+
let a = 7;
155+
let b = 8;
156+
dir.write_audio(a);
157+
let blocked = dir.block_wav(b);
158+
// Act
159+
let summary = purge_session_audio(dir.root(), &[a, b]);
160+
// Assert:终态**逐件可解释**(删掉 3 · 本不存在 0 · 失败 1),不是「全删或全不删」
161+
assert_eq!(summary.deleted, 3, "A 两件 + B 的 sidecar 应被删掉");
162+
assert_eq!(summary.absent, 0, "夹具前置:四件都在盘上");
163+
assert_eq!(summary.failed(), 1, "B 的 wav 应记 1 件失败");
164+
assert!(!dir.base.join(format!("{a}.wav")).exists(), "A 的音频必须不存在");
165+
assert!(!dir.base.join(format!("{a}.wav.meta.json")).exists(), "A 的 sidecar 必须不存在");
166+
assert!(!dir.base.join(format!("{b}.wav.meta.json")).exists(), "B 的 sidecar 必须不存在");
167+
assert!(blocked.exists(), "B 的 wav 仍在盘上(允许的终态,须登记而非假装成功)");
168+
}
169+
170+
#[test]
171+
fn batch_delete_clears_audio_and_keeps_unselected() {
172+
// Arrange:批量路径(第二条删除路径)——A/B 待删,keep 未勾选
173+
let db = mem_db();
174+
let dir = TempDataDir::new("v1-batch");
175+
let a = session(&db, "A课");
176+
let b = session(&db, "B课");
177+
let keep = session(&db, "保留课");
178+
for id in [a, b, keep] {
179+
dir.write_audio(id);
180+
}
181+
// Act
182+
let result = run_batch_delete_with_audio(&db, dir.root(), vec![a, b]).expect("批量删除成功");
183+
// Assert:DB 全删(单事务原子)+ 音频两件都清;未选会话的音频**原样保留**
184+
assert_eq!(result.deleted, 2);
185+
for id in [a, b] {
186+
assert!(db.get_session(id).expect("db").is_none(), "会话 {id} 记录必须消失");
187+
let (wav, sidecar) = session_audio_paths(&dir.base, id);
188+
assert!(!wav.exists(), "批量删除后音频必须不存在: {}", wav.display());
189+
assert!(!sidecar.exists(), "批量删除后 sidecar 必须不存在: {}", sidecar.display());
190+
}
191+
assert!(db.get_session(keep).expect("db").is_some(), "未勾选会话不得被删");
192+
assert!(dir.base.join(format!("{keep}.wav")).exists(), "未勾选会话的音频不得被清理");
193+
assert!(dir.base.join(format!("{keep}.wav.meta.json")).exists(), "未勾选会话的 sidecar 不得被清理");
194+
}
195+
196+
#[test]
197+
fn db_failure_leaves_audio_untouched() {
198+
// Arrange:触发器让 DELETE 失败(同批 4 既有手法)——顺序守卫:先 DB 后文件
199+
let db = mem_db();
200+
let dir = TempDataDir::new("v2-db-fail");
201+
let id = session(&db, "删不掉的课");
202+
dir.write_audio(id);
203+
db.with_conn(|conn| {
204+
// 触发器 DDL 不支持绑定变量——id 由本库 create_session 生成(非用户输入),内联安全
205+
let sql = format!(
206+
"CREATE TRIGGER guard_delete BEFORE DELETE ON sessions
207+
WHEN OLD.id = {} BEGIN SELECT RAISE(ABORT, 'guard: session busy'); END",
208+
id
209+
);
210+
conn.execute_batch(&sql)?;
211+
Ok(())
212+
})
213+
.expect("create guard trigger");
214+
// Act:DB 删除报错 ⇒ 整体上抛
215+
let err = run_batch_delete_with_audio(&db, dir.root(), vec![id]).expect_err("DB 失败必须上抛");
216+
// Assert:会话随事务回滚保留,**文件一个不动**(活会话不得丢音频)
217+
assert!(err.contains("guard: session busy"), "err={err}");
218+
assert!(db.get_session(id).expect("db").is_some(), "会话仍在(事务回滚)");
219+
let (wav, sidecar) = session_audio_paths(&dir.base, id);
220+
assert!(wav.exists(), "DB 失败时音频必须原样保留: {}", wav.display());
221+
assert!(sidecar.exists(), "DB 失败时 sidecar 必须原样保留: {}", sidecar.display());
222+
}
223+
224+
#[test]
225+
fn purge_ignores_audio_store_switch() {
226+
// Arrange:落盘开关**关闭**的配置(用户不要新录音)——但盘上已有历史录音
227+
let db = mem_db();
228+
let dir = TempDataDir::new("v2-config-off");
229+
let id = session(&db, "关掉落盘后要删的课");
230+
dir.write_audio(id);
231+
let cfg_path = dir.root().join("audio-store.json");
232+
std::fs::write(
233+
&cfg_path,
234+
r#"{"enabled":false,"retention_days":30,"disk_budget_bytes":4294967296}"#,
235+
)
236+
.expect("写 audio-store.json");
237+
// 前置:配置**确实**解析成关闭态(否则本用例会因配置读失败回落默认「开」而假绿)
238+
assert!(
239+
!crate::audio_store::AudioStoreConfig::load(&cfg_path).enabled,
240+
"夹具前置:audio-store.json 必须是可解析的关闭态"
241+
);
242+
// Act
243+
delete_session_in(&db, dir.root(), id).expect("删会话不应报错");
244+
// Assert:删音频与「是否落盘」无关(隐私面 > 设置面;🔴 不得读 AudioStoreConfig 决定删不删)
245+
let (wav, sidecar) = session_audio_paths(&dir.base, id);
246+
assert!(!wav.exists(), "关掉落盘不得让删会话漏掉音频: {}", wav.display());
247+
assert!(!sidecar.exists(), "sidecar 同样必须删掉: {}", sidecar.display());
248+
}

0 commit comments

Comments
 (0)