1use std::sync::Mutex;
22use std::time::Duration;
23
24use anyhow::Result;
25
26pub use crate::voice::det::{CountingUlidRng, SystemUlidRng, UlidRng};
27use crate::voice::transcriber::{
28 AudioInput, EndpointKind, EventId, EventStream, Transcriber, TranscriptEvent,
29};
30
31#[derive(Debug, Clone)]
35pub struct MockSegment {
36 pub text: String,
38 pub start: Duration,
40 pub end: Duration,
42 pub confidence: f32,
44}
45
46pub struct MockTranscriber {
49 script: Vec<MockSegment>,
50 rng: Mutex<Box<dyn UlidRng>>,
51}
52
53impl MockTranscriber {
54 pub fn new(script: Vec<MockSegment>) -> Self {
56 Self {
57 script,
58 rng: Mutex::new(Box::new(SystemUlidRng)),
59 }
60 }
61
62 pub fn with_rng(script: Vec<MockSegment>, rng: Box<dyn UlidRng>) -> Self {
65 Self {
66 script,
67 rng: Mutex::new(rng),
68 }
69 }
70
71 pub fn default_script() -> Vec<MockSegment> {
76 vec![
77 MockSegment {
78 text: "[mock transcriber] segment 1".to_string(),
79 start: Duration::from_millis(0),
80 end: Duration::from_secs(2),
81 confidence: 1.0,
82 },
83 MockSegment {
84 text: "[mock transcriber] segment 2".to_string(),
85 start: Duration::from_secs(2),
86 end: Duration::from_secs(5),
87 confidence: 1.0,
88 },
89 ]
90 }
91}
92
93impl Transcriber for MockTranscriber {
94 fn transcribe(&self, mut audio: Box<dyn AudioInput>) -> Result<Box<dyn EventStream>> {
95 let mut total_samples: usize = 0;
98 while let Some(chunk) = audio.next_chunk() {
99 total_samples = total_samples.saturating_add(chunk.len());
100 }
101 #[allow(clippy::cast_precision_loss)]
102 let total_duration = Duration::from_secs_f64(total_samples as f64 / 16_000.0);
103
104 let mut events: Vec<Result<TranscriptEvent>> = Vec::with_capacity(self.script.len() + 1);
105 for seg in &self.script {
106 let event_id: EventId = {
107 let mut rng = self
108 .rng
109 .lock()
110 .map_err(|e| anyhow::anyhow!("MockTranscriber RNG mutex poisoned: {e}"))?;
111 rng.next_ulid()
112 };
113 events.push(Ok(TranscriptEvent::Final {
114 event_id,
115 text: seg.text.clone(),
116 start: seg.start,
117 end: seg.end,
118 confidence: seg.confidence,
119 words: None,
120 speaker: None,
121 revisable: false,
122 }));
123 }
124 events.push(Ok(TranscriptEvent::Endpoint {
125 at: total_duration,
126 kind: EndpointKind::StreamEnd,
127 }));
128 Ok(Box::new(events.into_iter()))
129 }
130}
131
132#[cfg(test)]
133#[allow(clippy::unwrap_used, clippy::expect_used)]
134mod tests {
135 use super::*;
136 use crate::voice::transcriber::VecAudioInput;
137
138 fn run(transcriber: &MockTranscriber, samples: Vec<i16>) -> Vec<TranscriptEvent> {
139 let input = VecAudioInput::from_samples(samples, 1024);
140 transcriber
141 .transcribe(Box::new(input))
142 .unwrap()
143 .map(Result::unwrap)
144 .collect()
145 }
146
147 fn three_segment_script() -> Vec<MockSegment> {
148 vec![
149 MockSegment {
150 text: "alpha".into(),
151 start: Duration::from_millis(0),
152 end: Duration::from_millis(100),
153 confidence: 0.9,
154 },
155 MockSegment {
156 text: "beta".into(),
157 start: Duration::from_millis(100),
158 end: Duration::from_millis(200),
159 confidence: 0.95,
160 },
161 MockSegment {
162 text: "gamma".into(),
163 start: Duration::from_millis(200),
164 end: Duration::from_millis(300),
165 confidence: 0.99,
166 },
167 ]
168 }
169
170 #[test]
171 fn finals_precede_terminal_endpoint() {
172 let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
173 let events = run(&t, vec![0; 16_000]); assert_eq!(events.len(), 4);
175 for (i, e) in events.iter().take(3).enumerate() {
176 assert!(
177 matches!(e, TranscriptEvent::Final { .. }),
178 "event {i} should be Final, got {e:?}"
179 );
180 }
181 match &events[3] {
182 TranscriptEvent::Endpoint { kind, .. } => {
183 assert_eq!(*kind, EndpointKind::StreamEnd);
184 }
185 other => panic!("expected terminal Endpoint, got {other:?}"),
186 }
187 }
188
189 #[test]
190 fn every_final_has_revisable_false() {
191 let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
192 let events = run(&t, vec![0; 16_000]);
193 for e in &events {
194 if let TranscriptEvent::Final { revisable, .. } = e {
195 assert!(!revisable, "batch finals must not be revisable");
196 }
197 }
198 }
199
200 #[test]
201 fn ulids_are_monotonically_increasing() {
202 let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
203 let events = run(&t, vec![0; 16_000]);
204 let ids: Vec<EventId> = events
205 .iter()
206 .filter_map(|e| match e {
207 TranscriptEvent::Final { event_id, .. } => Some(*event_id),
208 _ => None,
209 })
210 .collect();
211 assert_eq!(ids.len(), 3);
212 for pair in ids.windows(2) {
213 assert!(
214 pair[0] < pair[1],
215 "ULIDs should be strictly increasing: {:?} -> {:?}",
216 pair[0],
217 pair[1]
218 );
219 }
220 }
221
222 #[test]
223 fn counting_ulid_rng_is_deterministic_across_runs() {
224 let s1 =
225 MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()))
226 .transcribe(Box::new(VecAudioInput::from_samples(vec![0; 16_000], 1024)))
227 .unwrap()
228 .map(Result::unwrap)
229 .collect::<Vec<_>>();
230 let s2 =
231 MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()))
232 .transcribe(Box::new(VecAudioInput::from_samples(vec![0; 16_000], 1024)))
233 .unwrap()
234 .map(Result::unwrap)
235 .collect::<Vec<_>>();
236 assert_eq!(s1, s2);
237 }
238
239 #[test]
240 fn endpoint_at_matches_total_audio_duration() {
241 let t = MockTranscriber::with_rng(vec![], Box::new(CountingUlidRng::new()));
242 let events = run(&t, vec![0; 32_000]);
244 assert_eq!(events.len(), 1);
245 match &events[0] {
246 TranscriptEvent::Endpoint { at, .. } => {
247 assert!(
248 (at.as_secs_f64() - 2.0).abs() < 1e-9,
249 "expected 2 s endpoint, got {at:?}"
250 );
251 }
252 other => panic!("expected Endpoint, got {other:?}"),
253 }
254 }
255
256 #[test]
257 fn empty_script_still_emits_endpoint() {
258 let t = MockTranscriber::with_rng(vec![], Box::new(CountingUlidRng::new()));
259 let events = run(&t, vec![0; 1_600]); assert_eq!(events.len(), 1);
261 assert!(matches!(events[0], TranscriptEvent::Endpoint { .. }));
262 }
263
264 #[test]
265 fn default_script_yields_two_segments() {
266 let script = MockTranscriber::default_script();
267 assert_eq!(script.len(), 2);
268 assert!(script[0].text.starts_with("[mock"));
269 }
270
271 #[test]
272 fn poisoned_rng_mutex_errors_cleanly() {
273 use std::panic::{self, AssertUnwindSafe};
278
279 struct PanickingRng;
280 impl UlidRng for PanickingRng {
281 fn next_ulid(&mut self) -> ulid::Ulid {
282 panic!("test-induced panic");
283 }
284 }
285
286 let script = vec![MockSegment {
287 text: "x".into(),
288 start: Duration::from_millis(0),
289 end: Duration::from_millis(100),
290 confidence: 1.0,
291 }];
292 let t = MockTranscriber::with_rng(script, Box::new(PanickingRng));
293
294 let first = panic::catch_unwind(AssertUnwindSafe(|| {
295 let input = VecAudioInput::from_samples(vec![0; 1_600], 1024);
296 let _ = t.transcribe(Box::new(input));
297 }));
298 assert!(first.is_err(), "first call should panic from PanickingRng");
299
300 let input = VecAudioInput::from_samples(vec![0; 1_600], 1024);
301 let Err(err) = t.transcribe(Box::new(input)) else {
302 panic!("expected poisoned-mutex error from transcribe");
303 };
304 assert!(
305 err.to_string().contains("poisoned"),
306 "expected poisoned mutex error, got: {err}"
307 );
308 }
309}