|
| 1 | +//! WAV 轴与会话轴对齐的边界单测(批 6 T23 / R5.5-b 第 2 条逐字:≥7 条边界)。 |
| 2 | +//! |
| 3 | +//! @ai-context: AAA 模式;**纯函数、零 IO、无墙钟**(夹具只用相对毫秒)。 |
| 4 | +//! 覆盖:首块 · 连续块(零空档)· 单空档 · 多空档 · 超大空档 · |
| 5 | +//! 时间戳回退/乱序 · 缺 `timestamp_ms` · 负时间戳 · 批量≡增量对拍 · |
| 6 | +//! **自证不变式**(含「发生过暂停」的会话形态)。 |
| 7 | +
|
| 8 | +use super::*; |
| 9 | + |
| 10 | +/// 会话纪元(毫秒;夹具只关心相对差)。 |
| 11 | +const T0: i64 = 1_000; |
| 12 | +/// 一块的样本数 = 200 ms @16 kHz(`audio_loopback.rs:338` 的 `block_samples`)。 |
| 13 | +const BLOCK: usize = 200 * SAMPLES_PER_MS; |
| 14 | +/// 一块的毫秒数(自证不变式的常数项:`timestamp_ms` 是块**发点** = 块末)。 |
| 15 | +const BLOCK_MS: i64 = 200; |
| 16 | + |
| 17 | +// ① 首块:T0 由首块锚定 ⇒ 不补 |
| 18 | +#[test] |
| 19 | +fn first_chunk_anchors_t0_without_padding() { |
| 20 | + // Arrange & Act & Assert |
| 21 | + assert_eq!(silence_gap_samples(None, Some(T0)), Some(0)); |
| 22 | + assert_eq!(silence_gap_samples(None, Some(0)), Some(0)); // 会话纪元处起步同样不补 |
| 23 | +} |
| 24 | + |
| 25 | +// ② 连续块(零空档):上一块末端 == 本块时间戳 ⇒ 不补 |
| 26 | +#[test] |
| 27 | +fn consecutive_blocks_need_zero_padding() { |
| 28 | + assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0 + 200)), Some(0)); |
| 29 | +} |
| 30 | + |
| 31 | +// ③ 单空档:补**等长**静音(200 ms ⇒ 200 × 16 样本) |
| 32 | +#[test] |
| 33 | +fn single_gap_pads_equal_length_silence() { |
| 34 | + assert_eq!(silence_gap_samples(Some(T0), Some(T0 + 200)), Some(200 * SAMPLES_PER_MS)); |
| 35 | + assert_eq!(silence_gap_samples(Some(T0), Some(T0 + 1)), Some(SAMPLES_PER_MS)); |
| 36 | +} |
| 37 | + |
| 38 | +// ④ 多空档:逐块独立、总数守恒(每块各补自己的空档) |
| 39 | +#[test] |
| 40 | +fn multiple_gaps_are_independent() { |
| 41 | + // Arrange:首块 T0 → 空档 300ms → 空档 700ms |
| 42 | + let fixture = [ |
| 43 | + (Some(T0), BLOCK), |
| 44 | + (Some(T0 + 200 + 300), BLOCK), |
| 45 | + (Some(T0 + 200 + 300 + 200 + 700), BLOCK), |
| 46 | + ]; |
| 47 | + // Act |
| 48 | + let plan = alignment_plan(&fixture); |
| 49 | + // Assert:首块 0;第 2 块补 300ms;第 3 块补 700ms(不叠加) |
| 50 | + assert_eq!(plan, vec![Some(0), Some(300 * SAMPLES_PER_MS), Some(700 * SAMPLES_PER_MS)]); |
| 51 | + let total: usize = fixture.iter().zip(&plan).map(|((_, n), p)| n + p.unwrap()).sum(); |
| 52 | + assert_eq!(total, 3 * BLOCK + 1_000 * SAMPLES_PER_MS); // 总数守恒 |
| 53 | +} |
| 54 | + |
| 55 | +// ⑤ 超大空档:恰好上限仍补,多 1 ms 即失效安全(不补) |
| 56 | +#[test] |
| 57 | +fn oversized_gap_fails_safe_at_the_limit() { |
| 58 | + assert_eq!( |
| 59 | + silence_gap_samples(Some(T0), Some(T0 + MAX_GAP_MS)), |
| 60 | + Some(MAX_GAP_MS as usize * SAMPLES_PER_MS) |
| 61 | + ); |
| 62 | + assert_eq!(silence_gap_samples(Some(T0), Some(T0 + MAX_GAP_MS + 1)), None); |
| 63 | +} |
| 64 | + |
| 65 | +// ⑥ 时间戳回退/乱序:一律不补(`None`),且相等不是回退 |
| 66 | +#[test] |
| 67 | +fn rollback_and_out_of_order_fail_safe() { |
| 68 | + assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0 + 100)), None); // 回退 100ms |
| 69 | + assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0)), None); // 乱序(退回更早) |
| 70 | + assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0 - 1)), None); |
| 71 | +} |
| 72 | + |
| 73 | +// ⑦ 缺 `timestamp_ms`:不补;会话级 ⇒ 该块与后续所有块都不补(纯追加) |
| 74 | +#[test] |
| 75 | +fn missing_timestamp_fails_safe() { |
| 76 | + assert_eq!(silence_gap_samples(Some(T0), None), None); |
| 77 | + assert_eq!(silence_gap_samples(None, None), None); // 首块就缺时间戳 ⇒ 无基准 |
| 78 | + let fixture = [(Some(T0), BLOCK), (None, BLOCK), (Some(T0 + 5_000), BLOCK)]; |
| 79 | + assert_eq!(alignment_plan(&fixture), vec![Some(0), None, None]); |
| 80 | +} |
| 81 | + |
| 82 | +// ⑧ 负时间戳:不补(`None`),含首块为负 |
| 83 | +#[test] |
| 84 | +fn negative_timestamp_fails_safe() { |
| 85 | + assert_eq!(silence_gap_samples(None, Some(-1)), None); |
| 86 | + assert_eq!(silence_gap_samples(Some(T0), Some(-200)), None); |
| 87 | +} |
| 88 | + |
| 89 | +// ⑨ 空簿记不冒充「已对齐」;一次偏差即**永久**失效(后续即便单调也不补) |
| 90 | +#[test] |
| 91 | +fn book_stays_unusable_after_a_single_deviation() { |
| 92 | + // Arrange:空簿记(如零样本会话) |
| 93 | + let mut book = AlignBook::new(); |
| 94 | + assert!(!book.is_aligned(), "无任何带时间戳的块 ⇒ 不得自称已对齐"); |
| 95 | + // Act:首块锚定 → 回退一次 → 恢复单调 |
| 96 | + assert_eq!(book.step(Some(T0), BLOCK), Some(0)); |
| 97 | + assert!(book.is_aligned()); |
| 98 | + assert_eq!(book.step(Some(T0 + 100), BLOCK), None); // 回退 ⇒ 永久失效 |
| 99 | + assert!(!book.is_aligned()); |
| 100 | + assert_eq!(book.step(Some(T0 + 100_000), BLOCK), None); // 失效后不再补 |
| 101 | + // Assert:失效后基准不再推进(纯追加不改变已写样本的会话轴末端) |
| 102 | + assert_eq!(book.first_ts_ms, Some(T0)); |
| 103 | + assert_eq!(book.last_end_ms, Some(T0 + 200)); |
| 104 | + assert_eq!(book.aligned, false); |
| 105 | +} |
| 106 | + |
| 107 | +// ⑩ 批量 ≡ 增量(对拍):两条路径在 ≥7 条夹具上逐元素相等 |
| 108 | +#[test] |
| 109 | +fn batch_plan_equals_incremental_driver_on_all_fixtures() { |
| 110 | + let fixtures: Vec<Vec<(Option<i64>, usize)>> = vec![ |
| 111 | + vec![], // 空序列 |
| 112 | + vec![(Some(T0), BLOCK)], // 首块 |
| 113 | + vec![(Some(T0), BLOCK), (Some(T0 + 200), BLOCK)], // 连续 |
| 114 | + vec![(Some(T0), BLOCK), (Some(T0 + 500), BLOCK)], // 单空档 |
| 115 | + vec![(Some(T0), BLOCK), (Some(T0 + 500), BLOCK), (Some(T0 + 1_500), BLOCK)], // 多空档 |
| 116 | + vec![(Some(T0), BLOCK), (Some(T0 + MAX_GAP_MS + 1), BLOCK)], // 超大空档 |
| 117 | + vec![(Some(T0 + 1_000), BLOCK), (Some(T0 + 500), BLOCK)], // 回退 |
| 118 | + vec![(Some(T0), BLOCK), (None, BLOCK), (Some(T0 + 9_000), BLOCK)], // 缺时间戳 |
| 119 | + vec![(Some(T0), BLOCK), (Some(-5), BLOCK)], // 负时间戳 |
| 120 | + vec![(None, BLOCK), (Some(T0), BLOCK)], // 首块缺时间戳 |
| 121 | + ]; |
| 122 | + for f in &fixtures { |
| 123 | + assert_eq!(alignment_plan(f), incremental_plan(f), "夹具 {:?}", f); |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +// ⑪ 自证不变式:补静音后「已写样本数 × 1000 ÷ 16000 ==(末块 ts − 首块 ts)+ 块长」, |
| 128 | +// 在「发生过暂停」的会话形态上同样成立(暂停两侧抵消 ⇒ 会话轴上不可见) |
| 129 | +#[test] |
| 130 | +fn self_proof_invariant_holds_across_pause_and_silence_window() { |
| 131 | + // Arrange:连续两块 →(暂停 30s:端点停采 + 时间戳补偿 ⇒ 会话轴冻结点接续)→ |
| 132 | + // 恢复后一块 → 2s 静默窗(无包)→ 收尾两块 |
| 133 | + let fixture = vec![ |
| 134 | + (Some(T0), BLOCK), |
| 135 | + (Some(T0 + 200), BLOCK), |
| 136 | + (Some(T0 + 400), BLOCK), // 暂停后恢复:会话轴上仍连续 |
| 137 | + (Some(T0 + 2_400), BLOCK), // 静默窗 1.8s ⇒ 补静音 |
| 138 | + (Some(T0 + 2_600), BLOCK), |
| 139 | + ]; |
| 140 | + // Act |
| 141 | + let plan = alignment_plan(&fixture); |
| 142 | + let written: u64 = fixture |
| 143 | + .iter() |
| 144 | + .zip(&plan) |
| 145 | + .map(|((_, n), p)| *n as u64 + p.unwrap_or(0) as u64) |
| 146 | + .sum(); |
| 147 | + // Assert:逐字读数 —— 44800 样本;2800 ms == (2600 − 1000) + 200 |
| 148 | + assert_eq!(plan, vec![Some(0), Some(0), Some(0), Some(1_800 * SAMPLES_PER_MS), Some(0)]); |
| 149 | + assert_eq!(written, 44_800); |
| 150 | + let first = fixture[0].0.unwrap(); |
| 151 | + let last = fixture[fixture.len() - 1].0.unwrap(); |
| 152 | + assert_eq!(written * 1000 / 16_000, (last - first + BLOCK_MS) as u64); |
| 153 | + // 等价形态:用「末块会话轴末端」(= `last_end_ms`)替代末块发点,二者差一个块长 |
| 154 | + let mut book = AlignBook::new(); |
| 155 | + for (ts, n) in &fixture { |
| 156 | + book.step(*ts, *n); |
| 157 | + } |
| 158 | + assert!(book.is_aligned()); |
| 159 | + assert_eq!(written * 1000 / 16_000, (book.last_end_ms.unwrap() - first) as u64); |
| 160 | +} |
| 161 | + |
| 162 | +/// 逐块驱动(**测试侧独立实现**:只调 `silence_gap_samples`,不复用 `AlignBook`)—— |
| 163 | +/// V5 对拍的基准:批量与增量两条路径分叉即红。 |
| 164 | +fn incremental_plan(chunks: &[(Option<i64>, usize)]) -> Vec<Option<usize>> { |
| 165 | + let mut out = Vec::new(); |
| 166 | + let mut aligned = true; |
| 167 | + let mut first: Option<i64> = None; |
| 168 | + let mut written: u64 = 0; |
| 169 | + for (ts, n) in chunks { |
| 170 | + if !aligned { |
| 171 | + out.push(None); |
| 172 | + written += *n as u64; |
| 173 | + continue; |
| 174 | + } |
| 175 | + let prev_end = first.map(|f| f + (written / SAMPLES_PER_MS as u64) as i64); |
| 176 | + match silence_gap_samples(prev_end, *ts) { |
| 177 | + Some(pad) => { |
| 178 | + if first.is_none() { |
| 179 | + first = *ts; |
| 180 | + } |
| 181 | + written += (pad + n) as u64; |
| 182 | + out.push(Some(pad)); |
| 183 | + } |
| 184 | + None => { |
| 185 | + aligned = false; |
| 186 | + written += *n as u64; |
| 187 | + out.push(None); |
| 188 | + } |
| 189 | + } |
| 190 | + } |
| 191 | + out |
| 192 | +} |
0 commit comments