1#![forbid(unsafe_code)]
40#![warn(missing_docs)]
41
42mod format;
43mod recorder;
44mod replay;
45
46pub use format::{CaptureHeader, CAPTURE_VERSION};
47pub use recorder::FileRecorder;
48pub use replay::FileReplayAdapter;
49
50use std::path::Path;
51
52use rvcsi_core::{CsiFrame, Result};
53
54pub fn read_all(path: impl AsRef<Path>) -> Result<(CaptureHeader, Vec<CsiFrame>)> {
65 use rvcsi_core::CsiSource;
66 let mut adapter = FileReplayAdapter::open(path)?;
67 let header = adapter.header().clone();
68 let mut frames = Vec::new();
69 while let Some(frame) = adapter.next_frame()? {
70 frames.push(frame);
71 }
72 Ok((header, frames))
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use rvcsi_core::{
79 AdapterKind, AdapterProfile, CsiSource, FrameId, RvcsiError, SessionId, SourceId,
80 ValidationStatus,
81 };
82 use std::fs::File;
83 use std::io::{Read, Write};
84
85 fn header() -> CaptureHeader {
86 CaptureHeader::new(
87 SessionId(1),
88 SourceId::from("it-test"),
89 AdapterProfile::offline(AdapterKind::File),
90 )
91 .with_created_unix_ns(0)
92 .with_calibration_version("room@v1")
93 .with_runtime_config_json(r#"{"window_ms":500}"#)
94 }
95
96 fn sample_frames() -> Vec<CsiFrame> {
100 let mut frames = Vec::new();
101
102 let mut f0 = CsiFrame::from_iq(
103 FrameId(0),
104 SessionId(1),
105 SourceId::from("it-test"),
106 AdapterKind::File,
107 1_000,
108 1,
109 20,
110 vec![1.0, 2.0, 3.0, 4.0],
111 vec![0.5, 0.5, 0.5, 0.5],
112 )
113 .with_rssi(-55);
114 f0.validation = ValidationStatus::Accepted;
115 f0.quality_score = 0.9;
116 frames.push(f0);
117
118 let mut f1 = CsiFrame::from_iq(
119 FrameId(1),
120 SessionId(1),
121 SourceId::from("it-test"),
122 AdapterKind::File,
123 2_000,
124 6,
125 40,
126 vec![0.1; 8],
127 vec![0.2; 8],
128 );
129 f1.validation = ValidationStatus::Degraded;
130 f1.quality_score = 0.4;
131 f1.quality_reasons = vec!["missing rssi".to_string(), "low snr".to_string()];
132 frames.push(f1);
133
134 let mut f2 = CsiFrame::from_iq(
135 FrameId(2),
136 SessionId(1),
137 SourceId::from("it-test"),
138 AdapterKind::File,
139 3_000,
140 11,
141 20,
142 vec![5.0, 6.0],
143 vec![1.0, -1.0],
144 )
145 .with_rssi(-70)
146 .with_noise_floor(-95);
147 f2.validation = ValidationStatus::Accepted;
148 f2.quality_score = 0.9;
149 frames.push(f2);
150
151 let mut f3 = CsiFrame::from_iq(
152 FrameId(3),
153 SessionId(1),
154 SourceId::from("it-test"),
155 AdapterKind::File,
156 2_500, 6,
158 20,
159 vec![0.0; 3],
160 vec![0.0; 3],
161 );
162 f3.validation = ValidationStatus::Recovered;
163 f3.quality_score = 0.3;
164 frames.push(f3);
165
166 let mut f4 = CsiFrame::from_iq(
167 FrameId(4),
168 SessionId(1),
169 SourceId::from("it-test"),
170 AdapterKind::File,
171 4_000,
172 36,
173 80,
174 vec![2.0; 6],
175 vec![0.0; 6],
176 );
177 f4.validation = ValidationStatus::Degraded;
178 f4.quality_score = 0.5;
179 f4.quality_reasons = vec!["amplitude spike".to_string()];
180 frames.push(f4);
181
182 frames
183 }
184
185 #[test]
186 fn record_then_replay_roundtrips_exactly() {
187 let tmp = tempfile::NamedTempFile::new().unwrap();
188 let header = header();
189 let frames = sample_frames();
190
191 let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
192 for f in &frames {
193 rec.write_frame(f).unwrap();
194 }
195 assert_eq!(rec.frames_written(), frames.len() as u64);
196 rec.finish().unwrap();
197
198 let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
199 assert_eq!(adapter.header(), &header);
200 let mut got = Vec::new();
201 while let Some(f) = adapter.next_frame().unwrap() {
202 got.push(f);
203 }
204 assert_eq!(got, frames);
205 assert_eq!(adapter.health().frames_delivered, frames.len() as u64);
206 assert!(!adapter.health().connected);
207 }
208
209 #[test]
210 fn re_serializing_replayed_frames_is_byte_identical() {
211 let tmp = tempfile::NamedTempFile::new().unwrap();
212 let header = header();
213 let frames = sample_frames();
214 let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
215 for f in &frames {
216 rec.write_frame(f).unwrap();
217 }
218 rec.finish().unwrap();
219
220 let mut original = String::new();
221 File::open(tmp.path()).unwrap().read_to_string(&mut original).unwrap();
222
223 let (h, fs) = read_all(tmp.path()).unwrap();
225 let tmp2 = tempfile::NamedTempFile::new().unwrap();
226 let mut rec2 = FileRecorder::create(tmp2.path(), &h).unwrap();
227 for f in &fs {
228 rec2.write_frame(f).unwrap();
229 }
230 rec2.finish().unwrap();
231 let mut reemitted = String::new();
232 File::open(tmp2.path()).unwrap().read_to_string(&mut reemitted).unwrap();
233
234 assert_eq!(original, reemitted);
235 }
236
237 #[test]
238 fn read_all_matches_replay() {
239 let tmp = tempfile::NamedTempFile::new().unwrap();
240 let header = header();
241 let frames = sample_frames();
242 let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
243 for f in &frames {
244 rec.write_frame(f).unwrap();
245 }
246 rec.finish().unwrap();
247
248 let (h, fs) = read_all(tmp.path()).unwrap();
249 assert_eq!(h, header);
250 assert_eq!(fs, frames);
251 }
252
253 #[test]
254 fn header_only_capture_has_no_frames() {
255 let tmp = tempfile::NamedTempFile::new().unwrap();
256 let header = header();
257 FileRecorder::create(tmp.path(), &header).unwrap().finish().unwrap();
258
259 let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
260 assert!(adapter.next_frame().unwrap().is_none());
261
262 let (h, fs) = read_all(tmp.path()).unwrap();
263 assert_eq!(h, header);
264 assert!(fs.is_empty());
265 }
266
267 #[test]
268 fn bad_header_line_is_parse_error_at_offset_zero() {
269 let tmp = tempfile::NamedTempFile::new().unwrap();
270 {
271 let mut f = File::create(tmp.path()).unwrap();
272 f.write_all(b"not json\n").unwrap();
273 }
274 match FileReplayAdapter::open(tmp.path()) {
275 Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
276 other => panic!("expected Parse at offset 0, got {other:?}"),
277 }
278 match read_all(tmp.path()) {
279 Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
280 other => panic!("expected Parse at offset 0, got {other:?}"),
281 }
282 }
283
284 #[test]
285 fn garbage_frame_after_good_frames_reports_line_number() {
286 let tmp = tempfile::NamedTempFile::new().unwrap();
287 let header = header();
288 {
289 let mut f = File::create(tmp.path()).unwrap();
290 serde_json::to_writer(&mut f, &header).unwrap();
291 f.write_all(b"\n").unwrap();
292 let frames = sample_frames();
294 serde_json::to_writer(&mut f, &frames[0]).unwrap();
295 f.write_all(b"\n").unwrap();
296 serde_json::to_writer(&mut f, &frames[1]).unwrap();
297 f.write_all(b"\n").unwrap();
298 f.write_all(b"{ not a frame }\n").unwrap();
300 }
301 let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
302 assert!(adapter.next_frame().unwrap().is_some()); assert!(adapter.next_frame().unwrap().is_some()); match adapter.next_frame() {
305 Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 4),
306 other => panic!("expected Parse at line 4, got {other:?}"),
307 }
308 }
309
310 #[test]
311 fn nonexistent_path_is_io_error() {
312 match FileReplayAdapter::open("/no/such/file/at/all.rvcsi") {
313 Err(RvcsiError::Io(_)) => {}
314 other => panic!("expected Io error, got {other:?}"),
315 }
316 match read_all("/no/such/file/at/all.rvcsi") {
317 Err(RvcsiError::Io(_)) => {}
318 other => panic!("expected Io error, got {other:?}"),
319 }
320 }
321
322 #[test]
323 fn counters_are_consistent() {
324 let tmp = tempfile::NamedTempFile::new().unwrap();
325 let header = header();
326 let frames = sample_frames();
327 let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
328 for (i, f) in frames.iter().enumerate() {
329 rec.write_frame(f).unwrap();
330 assert_eq!(rec.frames_written(), (i + 1) as u64);
331 }
332 rec.finish().unwrap();
333
334 let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
335 let mut n = 0u64;
336 while adapter.next_frame().unwrap().is_some() {
337 n += 1;
338 assert_eq!(adapter.health().frames_delivered, n);
339 }
340 assert_eq!(n, frames.len() as u64);
341 }
342}