1use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
97
98use crate::error::LogError;
99use crate::log::{Channel, FlightLog, LogFormat, Quantity, Stated};
100use crate::perfectflite::FOOT_M;
101
102const FORMAT: &str = "Raven FIP CSV";
104
105const ATM_PA: f64 = 101_325.0;
107
108const MPH_M_S: f64 = 0.447_04;
110
111const STATUS: &str = "bilba";
113
114const BARO_AGL: &str = "altitude (baro-ft-agl)";
116
117const BARE_AGL: &str = "altitude (ft-agl)";
119
120#[derive(Debug, Clone, Copy, PartialEq)]
122enum Scale {
123 By(f64),
125 Fahrenheit,
127}
128
129const KNOWN: [(&str, Quantity, Scale); 12] = [
133 (
134 "axial accel (gs)",
135 Quantity::Acceleration,
136 Scale::By(STANDARD_GRAVITY_MPS2),
137 ),
138 (
139 "lateral accel (gs)",
140 Quantity::Acceleration,
141 Scale::By(STANDARD_GRAVITY_MPS2),
142 ),
143 ("baro (atm)", Quantity::Pressure, Scale::By(ATM_PA)),
144 ("current draw (a)", Quantity::Current, Scale::By(1.0)),
145 ("temperature (f)", Quantity::Temperature, Scale::Fahrenheit),
146 (
147 "velocity (accel-ft/sec)",
148 Quantity::Speed,
149 Scale::By(FOOT_M),
150 ),
151 ("velocity (accel-mph)", Quantity::Speed, Scale::By(MPH_M_S)),
152 (
153 "altitude (accel-ft)",
154 Quantity::InertialAltitude,
155 Scale::By(FOOT_M),
156 ),
157 (BARO_AGL, Quantity::AltitudeAboveGround, Scale::By(FOOT_M)),
158 (BARE_AGL, Quantity::AltitudeAboveGround, Scale::By(FOOT_M)),
159 (
160 "altitude (baro-ft-asl)",
161 Quantity::AltitudeMsl,
162 Scale::By(FOOT_M),
163 ),
164 ("liftoff", Quantity::Number, Scale::By(1.0)),
165];
166
167fn key(name: &str) -> String {
169 let name = name.trim();
170 let name = name
171 .strip_prefix('[')
172 .and_then(|inner| inner.strip_suffix(']'))
173 .unwrap_or(name);
174 name.trim().to_ascii_lowercase()
175}
176
177fn known(name: &str) -> Option<(Quantity, Scale)> {
179 let key = key(name);
180 if key.starts_with("volts ") && key.ends_with("(v)") {
181 return Some((Quantity::Voltage, Scale::By(1.0)));
182 }
183 KNOWN
184 .iter()
185 .find(|(known, _, _)| *known == key)
186 .map(|(_, quantity, scale)| (*quantity, *scale))
187}
188
189fn is_clock(name: &str) -> bool {
191 name.get(..4)
192 .is_some_and(|start| start.eq_ignore_ascii_case("time"))
193}
194
195struct Column {
197 name: String,
198 quantity: Quantity,
199 scale: Scale,
200 values: Vec<f64>,
201 status: Option<Vec<f64>>,
202 empty: usize,
204 not_numbers: usize,
206 not_number_example: String,
207}
208
209struct Clock {
211 name: String,
212 time_s: Vec<f64>,
213 columns: Vec<Column>,
214 repeats: usize,
216 differing_repeats: usize,
217}
218
219impl Clock {
220 fn sample_width(&self) -> usize {
222 1 + self.columns.len() + self.columns.iter().filter(|c| c.status.is_some()).count()
223 }
224
225 fn empty_width(&self) -> usize {
227 1 + self.columns.len()
228 }
229}
230
231pub fn read(text: &str) -> Result<FlightLog, LogError> {
241 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
242 let mut lines = numbered_lines(text).filter(|(_, line)| !line.trim().is_empty());
243 let Some((header_line, header)) = lines.next() else {
244 return Err(LogError::NotThisFormat {
245 format: FORMAT,
246 message: "the file is empty".to_owned(),
247 });
248 };
249 let mut notes = Notes::default();
250 let mut clocks = header_clocks(header, header_line, &mut notes)?;
251
252 for (number, line) in lines {
253 read_row(&mut clocks, line, number)?;
254 }
255 build(clocks, header_line, notes)
256}
257
258fn numbered_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
260 let mut rest = Some(text);
261 let mut number = 0;
262 std::iter::from_fn(move || {
263 let current = rest?;
264 number += 1;
265 match current.find(['\r', '\n']) {
266 Some(end) => {
267 let skip = if current[end..].starts_with("\r\n") {
268 2
269 } else {
270 1
271 };
272 rest = Some(¤t[end + skip..]);
273 Some((number, ¤t[..end]))
274 }
275 None => {
276 rest = None;
277 (!current.is_empty()).then_some((number, current))
278 }
279 }
280 })
281}
282
283fn header_clocks(header: &str, line: usize, notes: &mut Notes) -> Result<Vec<Clock>, LogError> {
285 let names: Vec<&str> = header.split(',').map(str::trim).collect();
286 let is_raven = names.first().is_some_and(|first| is_clock(first))
287 && names.iter().any(|name| known(name).is_some());
288 if !is_raven {
289 return Err(LogError::NotThisFormat {
290 format: FORMAT,
291 message: format!(
292 "its first line, {:?}, isn't a FIP export's header, a clock such as \
293 `Time@Axial Accel (Gs)` and the Raven's channels",
294 short(header)
295 ),
296 });
297 }
298 let mut clocks: Vec<Clock> = Vec::new();
299 for (index, name) in names.iter().enumerate() {
300 let column_number = index + 1;
301 if name.is_empty() {
302 return Err(syntax(
303 line,
304 format!("the header's column {column_number} has no name"),
305 ));
306 }
307 if is_clock(name) {
308 if let Some(last) = clocks.last()
309 && last.columns.is_empty()
310 {
311 return Err(syntax(
312 line,
313 format!("the clock {:?} has no channel", short(&last.name)),
314 ));
315 }
316 clocks.push(Clock {
317 name: (*name).to_owned(),
318 time_s: Vec::new(),
319 columns: Vec::new(),
320 repeats: 0,
321 differing_repeats: 0,
322 });
323 continue;
324 }
325 let Some(clock) = clocks.last_mut() else {
327 continue;
328 };
329 if name.eq_ignore_ascii_case(STATUS) {
330 match clock.columns.last_mut() {
331 Some(column) if column.status.is_none() => column.status = Some(Vec::new()),
332 _ => {
333 return Err(syntax(
334 line,
335 format!("the status column {column_number} follows no channel of its own"),
336 ));
337 }
338 }
339 continue;
340 }
341 let (quantity, scale) = known(name).unwrap_or_else(|| {
342 notes.push(format!(
343 "the column {:?} isn't one this reader knows, and was kept as written",
344 short(name)
345 ));
346 (Quantity::Unrecognized, Scale::By(1.0))
347 });
348 clock.columns.push(Column {
349 name: (*name).to_owned(),
350 quantity,
351 scale,
352 values: Vec::new(),
353 status: None,
354 empty: 0,
355 not_numbers: 0,
356 not_number_example: String::new(),
357 });
358 }
359 if let Some(last) = clocks.last()
360 && last.columns.is_empty()
361 {
362 return Err(syntax(
363 line,
364 format!("the clock {:?} has no channel", short(&last.name)),
365 ));
366 }
367 Ok(clocks)
368}
369
370fn read_row(clocks: &mut [Clock], line: &str, number: usize) -> Result<(), LogError> {
373 let cells: Vec<&str> = line.split(',').map(str::trim).collect();
374 let mut at = 0;
375 for clock in clocks.iter_mut() {
376 let empty_width = clock.empty_width();
377 let Some(head) = cells.get(at..at + empty_width) else {
378 return Err(syntax(
379 number,
380 format!(
381 "the row ends at cell {} before the clock {:?}'s cells",
382 cells.len(),
383 short(&clock.name)
384 ),
385 ));
386 };
387 if head.iter().all(|cell| cell.is_empty()) {
388 at += empty_width;
389 continue;
390 }
391 let width = clock.sample_width();
392 let Some(sample) = cells.get(at..at + width) else {
393 return Err(syntax(
394 number,
395 format!(
396 "the row ends at cell {} inside the clock {:?}'s sample",
397 cells.len(),
398 short(&clock.name)
399 ),
400 ));
401 };
402 read_sample(clock, sample, number, at)?;
403 at += width;
404 }
405 if at != cells.len() {
406 return Err(syntax(
407 number,
408 format!(
409 "the row has {} cells and its channels' samples take {at}",
410 cells.len()
411 ),
412 ));
413 }
414 Ok(())
415}
416
417fn read_sample(
419 clock: &mut Clock,
420 sample: &[&str],
421 number: usize,
422 first: usize,
423) -> Result<(), LogError> {
424 let time = sample[0];
425 let time_s = time
426 .parse::<f64>()
427 .ok()
428 .filter(|value| value.is_finite())
429 .ok_or_else(|| {
430 syntax(
431 number,
432 format!(
433 "the time {:?} in cell {} isn't a finite number",
434 short(time),
435 first + 1
436 ),
437 )
438 })?;
439 let mut parsed: Vec<(f64, Option<f64>)> = Vec::with_capacity(clock.columns.len());
441 let mut at = 1;
442 for column in &mut clock.columns {
443 let cell = sample[at];
444 let value = value_cell(column, cell).ok_or_else(|| {
445 syntax(
446 number,
447 format!(
448 "{:?} in cell {}, under {:?}, isn't a number",
449 short(cell),
450 first + at + 1,
451 short(&column.name)
452 ),
453 )
454 })?;
455 at += 1;
456 let status = if column.status.is_some() {
457 let cell = sample[at];
458 let status = status_cell(cell).ok_or_else(|| {
459 syntax(
460 number,
461 format!(
462 "the status {:?} in cell {} isn't `b` and binary digits",
463 short(cell),
464 first + at + 1
465 ),
466 )
467 })?;
468 at += 1;
469 Some(status)
470 } else {
471 None
472 };
473 parsed.push((value, status));
474 }
475 if let Some(&last) = clock.time_s.last() {
476 if time_s < last {
477 return Err(syntax(
478 number,
479 format!(
480 "the time {time_s} s on the clock {:?} comes before the one above it, {last} s",
481 short(&clock.name)
482 ),
483 ));
484 }
485 if time_s == last {
486 clock.repeats += 1;
487 let same = clock
488 .columns
489 .iter()
490 .zip(&parsed)
491 .all(|(column, (value, status))| {
492 same_bits(column.values.last(), Some(value))
493 && same_bits(
494 column.status.as_ref().and_then(|statuses| statuses.last()),
495 status.as_ref(),
496 )
497 });
498 if !same {
499 clock.differing_repeats += 1;
500 }
501 return Ok(());
502 }
503 }
504 clock.time_s.push(time_s);
505 for (column, (value, status)) in clock.columns.iter_mut().zip(parsed) {
506 column.values.push(value);
507 if let (Some(statuses), Some(status)) = (column.status.as_mut(), status) {
508 statuses.push(status);
509 }
510 }
511 Ok(())
512}
513
514fn same_bits(a: Option<&f64>, b: Option<&f64>) -> bool {
516 match (a, b) {
517 (Some(a), Some(b)) => a.to_bits() == b.to_bits(),
518 (None, None) => true,
519 _ => false,
520 }
521}
522
523fn value_cell(column: &mut Column, cell: &str) -> Option<f64> {
526 if cell.is_empty() {
527 column.empty += 1;
528 return Some(f64::NAN);
529 }
530 match cell.parse::<f64>() {
531 Ok(value) if value.is_finite() => Some(si(column.scale, value)),
532 Ok(_) => Some(not_a_number(column, cell)),
533 Err(_) if is_windows_non_number(cell) => Some(not_a_number(column, cell)),
534 Err(_) => None,
535 }
536}
537
538fn not_a_number(column: &mut Column, cell: &str) -> f64 {
540 if column.not_numbers == 0 {
541 column.not_number_example = short(cell);
542 }
543 column.not_numbers += 1;
544 f64::NAN
545}
546
547fn is_windows_non_number(cell: &str) -> bool {
550 let unsigned = cell.trim_start_matches(['-', '+']);
551 let Some(rest) = unsigned.strip_prefix("1.#") else {
552 return false;
553 };
554 let word = rest.trim_end_matches(|c: char| c.is_ascii_digit());
555 ["QNAN", "SNAN", "IND", "INF"]
556 .iter()
557 .any(|known| word.eq_ignore_ascii_case(known))
558}
559
560fn status_cell(cell: &str) -> Option<f64> {
564 if cell.is_empty() {
565 return Some(f64::NAN);
566 }
567 let digits = cell.strip_prefix(['b', 'B'])?;
568 if digits.is_empty() || digits.len() > 52 || !digits.bytes().all(|b| b == b'0' || b == b'1') {
569 return None;
570 }
571 u64::from_str_radix(digits, 2).ok().map(|bits| bits as f64)
573}
574
575fn si(scale: Scale, value: f64) -> f64 {
577 match scale {
578 Scale::By(factor) => value * factor,
579 Scale::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
580 }
581}
582
583fn build(clocks: Vec<Clock>, header_line: usize, mut notes: Notes) -> Result<FlightLog, LogError> {
585 let mut log = FlightLog {
586 format: LogFormat::RavenFipCsv,
587 logger: "Featherweight Raven".to_owned(),
588 serial_number: None,
589 firmware: None,
590 flight_number: None,
591 stated: Stated::default(),
592 time_s: Vec::new(),
593 altitude_m: Vec::new(),
594 vertical_speed_m_s: None,
595 temperature_k: None,
596 battery_v: None,
597 channels: Vec::new(),
598 events: Vec::new(),
599 notes: Vec::new(),
600 };
601 for clock in clocks {
602 if clock.repeats > 0 {
603 notes.push(format!(
604 "the clock {:?} repeats the time before it {} times; the repeats were dropped, \
605 {} of them differing from the sample they repeat",
606 short(&clock.name),
607 clock.repeats,
608 clock.differing_repeats
609 ));
610 }
611 for column in clock.columns {
612 if column.not_numbers > 0 {
613 notes.push(format!(
614 "the column {:?} holds {} cells that aren't a number, such as {:?}: they are \
615 gaps",
616 short(&column.name),
617 column.not_numbers,
618 column.not_number_example
619 ));
620 }
621 if column.empty > 0 {
622 notes.push(format!(
623 "the column {:?} leaves {} samples' values empty: they are gaps",
624 short(&column.name),
625 column.empty
626 ));
627 }
628 log.channels.push(Channel {
629 column: column.name,
630 quantity: column.quantity,
631 time_s: clock.time_s.clone(),
632 values: column.values,
633 });
634 if let Some(status) = column.status {
635 log.channels.push(Channel {
636 column: "bILBA".to_owned(),
637 quantity: Quantity::Number,
638 time_s: clock.time_s.clone(),
639 values: status,
640 });
641 }
642 }
643 }
644
645 let primary = log
646 .channels
647 .iter()
648 .find(|channel| key(&channel.column) == BARO_AGL)
649 .or_else(|| {
650 let bare = log
651 .channels
652 .iter()
653 .find(|channel| key(&channel.column) == BARE_AGL);
654 if let Some(channel) = bare {
655 log.notes.push(format!(
656 "the file has no `[Altitude (Baro-Ft-AGL)]`; its {:?}, which names no sensor, \
657 was taken as the altitude above the pad",
658 channel.column
659 ));
660 }
661 bare
662 });
663 let Some(primary) = primary else {
664 return Err(syntax(
665 header_line,
666 "the file has no altitude above the pad, `[Altitude (Baro-Ft-AGL)]` or `Altitude \
667 (Ft-AGL)`"
668 .to_owned(),
669 ));
670 };
671 if primary.time_s.is_empty() {
672 return Err(LogError::NoData { format: FORMAT });
673 }
674 log.time_s.clone_from(&primary.time_s);
675 log.altitude_m.clone_from(&primary.values);
676 let on_primary_clock = |channel: &&Channel| channel.time_s == log.time_s;
678 let temperature = log
679 .channels_of(Quantity::Temperature)
680 .find(on_primary_clock)
681 .map(|channel| channel.values.clone());
682 let battery = log
683 .channels_of(Quantity::Voltage)
684 .filter(|channel| key(&channel.column).contains("battery"))
685 .find(on_primary_clock)
686 .map(|channel| channel.values.clone());
687 log.temperature_k = temperature;
688 log.battery_v = battery;
689
690 log.notes.extend(notes.kept);
691 if notes.left_out > 0 {
692 log.notes.push(format!(
693 "and {} more notes, left out so that a broken file's list stays short",
694 notes.left_out
695 ));
696 }
697 Ok(log)
698}
699
700const MAX_NOTES: usize = 20;
702
703#[derive(Default)]
706struct Notes {
707 kept: Vec<String>,
708 left_out: usize,
709}
710
711impl Notes {
712 fn push(&mut self, note: String) {
713 if self.kept.len() < MAX_NOTES {
714 self.kept.push(note);
715 } else {
716 self.left_out += 1;
717 }
718 }
719}
720
721fn syntax(line: usize, message: String) -> LogError {
722 LogError::Syntax {
723 format: FORMAT,
724 line,
725 message,
726 }
727}
728
729fn short(text: &str) -> String {
732 if text.chars().count() <= QUOTED_CHARS {
733 text.to_owned()
734 } else {
735 let mut cut: String = text.chars().take(QUOTED_CHARS - 1).collect();
736 cut.push('…');
737 cut
738 }
739}
740
741const QUOTED_CHARS: usize = 80;
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 const HEADER: &str = "Time@Axial Accel (Gs),Axial Accel (Gs),bILBA,\
749 Time@Baro (Atm),Baro (Atm),bILBA,\
750 Time@[Altitude (Baro-Ft-AGL)],[Altitude (Baro-Ft-AGL)],bILBA";
751
752 fn close(a: f64, b: f64) -> bool {
753 (a - b).abs() <= 1e-12 * b.abs().max(1.0)
754 }
755
756 #[test]
759 fn reads_each_channel_on_its_own_clock() {
760 let text = format!(
761 "{HEADER}\r\n\
762 0,1,b0000,0.01,1,b0000,0.01,0,b0000\r\n\
763 0.0025,2,b0101,0.06,0.5,b1100,0.06,100,b1100\r\n"
764 );
765 let log = read(&text).unwrap();
766 assert_eq!(log.format, LogFormat::RavenFipCsv);
767 assert_eq!(log.logger, "Featherweight Raven");
768 let columns: Vec<&str> = log.channels.iter().map(|c| c.column.as_str()).collect();
769 assert_eq!(
770 columns,
771 [
772 "Axial Accel (Gs)",
773 "bILBA",
774 "Baro (Atm)",
775 "bILBA",
776 "[Altitude (Baro-Ft-AGL)]",
777 "bILBA"
778 ]
779 );
780 assert_eq!(log.channels[0].time_s, [0.0, 0.0025]);
781 assert_eq!(log.channels[0].values, [9.806_65, 2.0 * 9.806_65]);
782 assert_eq!(log.channels[1].values, [0.0, 5.0]);
783 assert_eq!(log.channels[2].values, [101_325.0, 50_662.5]);
784 assert_eq!(log.channels[3].values, [0.0, 12.0]);
785 assert_eq!(log.time_s, [0.01, 0.06]);
786 assert_eq!(log.altitude_m, [0.0, 30.48]);
787 assert!(log.vertical_speed_m_s.is_none());
788 assert!(log.events.is_empty());
789 assert!(log.notes.is_empty(), "{:?}", log.notes);
790 }
791
792 #[test]
795 fn a_stopped_channel_drops_its_status_cell() {
796 let text = format!(
797 "{HEADER}\n\
798 0,1,b0000,0.01,1,b0000,0.01,0,b0000\n\
799 0.0025,1,b0000,,,0.06,10,b1000\n\
800 0.005,1,b0000,,,,\n\
801 ,,,,,\n"
802 );
803 let log = read(&text).unwrap();
804 assert_eq!(log.channels[0].time_s, [0.0, 0.0025, 0.005]);
805 assert_eq!(log.channels[2].time_s, [0.01]);
806 assert_eq!(log.time_s, [0.01, 0.06]);
807 assert!(close(log.altitude_m[1], 3.048));
808 assert_eq!(log.channels[5].values, [0.0, 8.0]);
809
810 let text = format!("{HEADER}\n0.0025,1,b0000,,,,0.06,10,b1000\n");
812 let error = read(&text).unwrap_err().to_string();
813 assert!(error.contains("line 2:"), "{error}");
814 }
815
816 #[test]
819 fn without_status_columns_rows_keep_their_width() {
820 let text = "Time@Axial Accel (Gs),Axial Accel (Gs),Time@Temperature (F),Temperature (F),\
821 Time@[Altitude (Baro-Ft-AGL)],[Altitude (Baro-Ft-AGL)]\n\
822 0,1,0.01,32,0.01,0\n\
823 0.0025,1,,,0.06,1\n";
824 let log = read(text).unwrap();
825 assert_eq!(log.channels.len(), 3);
826 assert_eq!(log.channels[1].values, [273.15]);
827 assert!(log.temperature_k.is_none(), "not on the altitude's clock");
828 assert_eq!(log.time_s, [0.01, 0.06]);
829 }
830
831 #[test]
834 fn one_clock_layout_fills_the_track() {
835 let text = "Time,LiftOff,Axial Accel (Gs),Temperature (F),Volts Battery (V),\
836 Volts Pyro Main (V),[Altitude (Baro-Ft-AGL)],[Velocity (Accel-MPH)]\n\
837 0,0,1,212,8.1,0.1,0,0\n\
838 0.001,0,1,212,8.1,0.1,0,0\n\
839 0.001,0,1,212,8.1,0.1,0,0\n\
840 0.002,1,5,212,8.0,0.1,1,10\n";
841 let log = read(text).unwrap();
842 assert_eq!(log.time_s, [0.0, 0.001, 0.002]);
843 let temperature = log.temperature_k.as_deref().unwrap();
844 assert!(close(temperature[0], 373.15));
845 assert_eq!(log.battery_v.as_deref(), Some(&[8.1, 8.1, 8.0][..]));
846 assert_eq!(log.channels[0].quantity, Quantity::Number);
847 assert!(close(log.channels[6].values[2], 4.4704));
848 assert_eq!(
849 log.notes,
850 [
851 "the clock \"Time\" repeats the time before it 1 times; the repeats were dropped, 0 \
852 of them differing from the sample they repeat"
853 ]
854 );
855 }
856
857 #[test]
859 fn the_bare_altitude_is_taken_with_a_note() {
860 let text = "Time-Axial Accel (Gs), Axial Accel (Gs), bILBA, Time (s), Altitude (Ft-AGL), \
861 bILBA\n0, 1, b0000, 0, 10, b1000\n";
862 let log = read(text).unwrap();
863 assert_eq!(log.altitude_m, [3.048]);
864 assert!(log.notes[0].contains("names no sensor"), "{:?}", log.notes);
865 }
866
867 #[test]
869 fn windows_non_numbers_are_gaps() {
870 let text = format!(
871 "{HEADER}\n0,-1.#QNAN,b0000,0.01,,b0000,0.01,0,b0000\n\
872 0.0025,1.#INF,b0000,0.06,1,b0000,0.06,0,b0000\n"
873 );
874 let log = read(&text).unwrap();
875 assert!(log.channels[0].values.iter().all(|v| v.is_nan()));
876 assert!(log.channels[2].values[0].is_nan());
877 assert_eq!(log.notes.len(), 2, "{:?}", log.notes);
878 assert!(log.notes[0].contains("2 cells that aren't a number, such as \"-1.#QNAN\""));
879 assert!(log.notes[1].contains("leaves 1 samples' values empty"));
880 assert!(is_windows_non_number("-1.#IND00"));
881 assert!(!is_windows_non_number("1.#X"));
882 let error = read(&format!("{HEADER}\n0,x,b0000,,,,\n")).unwrap_err();
883 assert!(
884 error.to_string().contains("line 2: \"x\" in cell 2"),
885 "{error}"
886 );
887 }
888
889 #[test]
892 fn status_cells_are_binary_numbers() {
893 assert_eq!(status_cell("b0101"), Some(5.0));
894 assert_eq!(status_cell("b1000"), Some(8.0));
895 assert_eq!(status_cell("b1111"), Some(15.0));
896 assert!(status_cell("").unwrap().is_nan());
897 for bad in ["0101", "b", "b012", "bx", &format!("b{}", "1".repeat(53))] {
898 assert_eq!(status_cell(bad), None, "{bad}");
899 }
900 }
901
902 #[test]
904 fn malformed_files_are_refused_where_they_go_wrong() {
905 let cases = [
906 (String::new(), "the file is empty"),
907 (
908 "Time,Altitude\n0,0\n".to_owned(),
909 "isn't a FIP export's header",
910 ),
911 (
912 "Axial Accel (Gs),Time\n".to_owned(),
913 "isn't a FIP export's header",
914 ),
915 (format!("{HEADER}\n"), "no data rows"),
916 (
917 "Time@Axial Accel (Gs),Axial Accel (Gs)\n0,1\n".to_owned(),
918 "line 1: the file has no altitude above the pad",
919 ),
920 (
921 "Time@x,Time@Axial Accel (Gs),Axial Accel (Gs)\n".to_owned(),
922 "line 1: the clock \"Time@x\" has no channel",
923 ),
924 (
925 "Time@Axial Accel (Gs),bILBA,Axial Accel (Gs)\n".to_owned(),
926 "line 1: the status column 2 follows no channel",
927 ),
928 (
929 "Time@Axial Accel (Gs),Axial Accel (Gs),,x\n".to_owned(),
930 "line 1: the header's column 3 has no name",
931 ),
932 (
933 format!("{HEADER}\n0,1,b0,0.5,1,b0,0.5,0,b0\n0,1,b0,0.4,1,b0,0.6,0,b0\n"),
934 "line 3: the time 0.4 s on the clock \"Time@Baro (Atm)\" comes before",
935 ),
936 (
937 format!("{HEADER}\nx,1,b0,,,,\n"),
938 "line 2: the time \"x\" in cell 1",
939 ),
940 (
941 format!("{HEADER}\n0,1,b0,0,1,b0,0,1,b2\n"),
942 "line 2: the status \"b2\" in cell 9",
943 ),
944 (
945 format!("{HEADER}\n0,1,b0,0,1\n"),
946 "line 2: the row ends at cell 5",
947 ),
948 (
949 format!("{HEADER}\n0,1,b0,,,0.1,1,b0,7\n"),
950 "line 2: the row has 9 cells and its channels' samples take 8",
951 ),
952 (format!("{HEADER}\n0,1,b0,,,,\n"), "no data rows"),
953 ];
954 for (text, expected) in cases {
955 let error = read(&text).unwrap_err().to_string();
956 assert!(error.contains(expected), "{text:?}: {error}");
957 }
958 }
959
960 #[test]
962 fn marks_and_line_ends() {
963 let text = format!("\u{feff}\r{HEADER}\r0,1,b0,0,1,b0,0,1,b0\r\r");
964 let log = read(&text).unwrap();
965 assert_eq!(log.altitude_m, [FOOT_M]);
966 let lines: Vec<(usize, &str)> = numbered_lines("a\r\nb\rc\n\nd").collect();
967 assert_eq!(lines, [(1, "a"), (2, "b"), (3, "c"), (4, ""), (5, "d")]);
968 }
969
970 #[test]
972 fn notes_stay_few() {
973 let unknown: String = (0..100).map(|i| format!(",u{i}")).collect();
974 let text = format!("Time@[Altitude (Baro-Ft-AGL)],[Altitude (Baro-Ft-AGL)]{unknown}\n");
975 let row = format!("0{}\n", ",1".repeat(101));
976 let log = read(&format!("{text}{row}")).unwrap();
977 assert_eq!(log.notes.len(), MAX_NOTES + 1);
978 assert!(
979 log.notes[MAX_NOTES].starts_with("and 80 more notes"),
980 "{:?}",
981 log.notes
982 );
983 assert_eq!(log.channels.len(), 101);
984 assert_eq!(log.channels[100].quantity, Quantity::Unrecognized);
985 }
986
987 #[test]
989 fn a_long_line_is_quoted_short() {
990 let junk = "\u{fffd}".repeat(100_000);
991 let error = read(&junk).unwrap_err().to_string();
992 assert!(error.len() < 400, "{} bytes", error.len());
993 }
994
995 proptest::proptest! {
996 #[test]
999 fn any_text_is_read_or_refused(rows in proptest::collection::vec("[-0-9.b,x#QNA]{0,24}", 0..10)) {
1000 let text = format!("{HEADER}\n{}", rows.join("\n"));
1001 if let Ok(log) = read(&text) {
1002 proptest::prop_assert!(log.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1003 proptest::prop_assert_eq!(log.time_s.len(), log.altitude_m.len());
1004 for channel in &log.channels {
1005 proptest::prop_assert!(channel.time_s.windows(2).all(|pair| pair[1] > pair[0]));
1006 proptest::prop_assert_eq!(channel.time_s.len(), channel.values.len());
1007 }
1008 }
1009 }
1010 }
1011}