|
| 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