1use serde::{Deserialize, Serialize};
6
7use crate::id::{Blake3Hex, NodeIdHex};
8use crate::util::{canonical_utc_now, is_canonical_utc_timestamp};
9
10#[derive(Debug, thiserror::Error)]
13pub enum MetadataError {
14 #[error("wrong schema: expected \"igc-net/metadata\", got {0:?}")]
15 WrongSchema(String),
16 #[error("unsupported schema_version: {0}")]
17 WrongVersion(u32),
18 #[error("JSON: {0}")]
19 Json(#[from] serde_json::Error),
20 #[error("{field} is not a valid 64-char lowercase hex string")]
21 MalformedLowerHex { field: &'static str },
22 #[error("{field} is not a canonical UTC timestamp: {value}")]
23 MalformedTimestamp { field: &'static str, value: String },
24 #[error("{field} is not a valid YYYY-MM-DD date: {value}")]
25 MalformedDate { field: &'static str, value: String },
26 #[error("{field} is out of range or non-finite: {value}")]
27 InvalidCoordinate { field: &'static str, value: f64 },
28 #[error("{field} has invalid bounds: {message}")]
29 InvalidBounds {
30 field: &'static str,
31 message: &'static str,
32 },
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct BoundingBox {
39 pub min_lat: f64,
40 pub max_lat: f64,
41 pub min_lon: f64,
42 pub max_lon: f64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct FlightMetadata {
52 pub schema: String,
53 pub schema_version: u32,
54 pub igc_hash: Blake3Hex,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub original_filename: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub flight_date: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub started_at: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub ended_at: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub duration_s: Option<u64>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub pilot_name: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub glider_type: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub glider_id: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub device_id: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub fix_count: Option<u32>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub valid_fix_count: Option<u32>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub bbox: Option<BoundingBox>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub launch_lat: Option<f64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub launch_lon: Option<f64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub landing_lat: Option<f64>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub landing_lon: Option<f64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub max_alt_m: Option<i32>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub min_alt_m: Option<i32>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub publisher_node_id: Option<NodeIdHex>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub published_at: Option<String>,
97}
98
99impl FlightMetadata {
102 pub fn new(igc_hash: Blake3Hex) -> Self {
104 FlightMetadata {
105 schema: "igc-net/metadata".to_string(),
106 schema_version: 1,
107 igc_hash,
108 original_filename: None,
109 flight_date: None,
110 started_at: None,
111 ended_at: None,
112 duration_s: None,
113 pilot_name: None,
114 glider_type: None,
115 glider_id: None,
116 device_id: None,
117 fix_count: None,
118 valid_fix_count: None,
119 bbox: None,
120 launch_lat: None,
121 launch_lon: None,
122 landing_lat: None,
123 landing_lon: None,
124 max_alt_m: None,
125 min_alt_m: None,
126 publisher_node_id: None,
127 published_at: None,
128 }
129 }
130
131 pub fn from_igc_bytes(
142 igc_bytes: &[u8],
143 igc_hash: Blake3Hex,
144 original_filename: Option<&str>,
145 publisher_node_id: Option<NodeIdHex>,
146 ) -> Self {
147 let mut meta = FlightMetadata::new(igc_hash);
148 meta.original_filename = original_filename.map(str::to_string);
149 meta.publisher_node_id = publisher_node_id;
150 meta.published_at = Some(canonical_utc_now());
151
152 let text = match std::str::from_utf8(igc_bytes) {
153 Ok(t) => t,
154 Err(_) => return meta, };
156
157 parse_igc_into(text, &mut meta);
158 meta
159 }
160
161 pub fn to_blob_bytes(&self) -> Result<Vec<u8>, MetadataError> {
165 serde_json::to_vec(self).map_err(MetadataError::from)
166 }
167
168 pub fn validate(&self) -> Result<(), MetadataError> {
170 if self.schema != "igc-net/metadata" {
171 return Err(MetadataError::WrongSchema(self.schema.clone()));
172 }
173 if self.schema_version != 1 {
174 return Err(MetadataError::WrongVersion(self.schema_version));
175 }
176 if let Some(value) = self.flight_date.as_deref()
177 && !is_canonical_date(value)
178 {
179 return Err(MetadataError::MalformedDate {
180 field: "flight_date",
181 value: value.to_string(),
182 });
183 }
184 for (field, value) in [
185 ("started_at", self.started_at.as_deref()),
186 ("ended_at", self.ended_at.as_deref()),
187 ("published_at", self.published_at.as_deref()),
188 ] {
189 if let Some(value) = value
190 && !is_canonical_utc_timestamp(value)
191 {
192 return Err(MetadataError::MalformedTimestamp {
193 field,
194 value: value.to_string(),
195 });
196 }
197 }
198 for (field, value, min, max) in [
199 ("launch_lat", self.launch_lat, -90.0, 90.0),
200 ("launch_lon", self.launch_lon, -180.0, 180.0),
201 ("landing_lat", self.landing_lat, -90.0, 90.0),
202 ("landing_lon", self.landing_lon, -180.0, 180.0),
203 ] {
204 if let Some(value) = value {
205 validate_coordinate(field, value, min, max)?;
206 }
207 }
208 if let Some(bb) = &self.bbox {
209 validate_coordinate("bbox.min_lat", bb.min_lat, -90.0, 90.0)?;
210 validate_coordinate("bbox.max_lat", bb.max_lat, -90.0, 90.0)?;
211 validate_coordinate("bbox.min_lon", bb.min_lon, -180.0, 180.0)?;
212 validate_coordinate("bbox.max_lon", bb.max_lon, -180.0, 180.0)?;
213 if bb.max_lat < bb.min_lat {
214 return Err(MetadataError::InvalidBounds {
215 field: "bbox",
216 message: "max_lat < min_lat",
217 });
218 }
219 if bb.max_lon < bb.min_lon {
220 return Err(MetadataError::InvalidBounds {
221 field: "bbox",
222 message: "max_lon < min_lon",
223 });
224 }
225 }
226 Ok(())
227 }
228}
229
230fn parse_igc_into(text: &str, meta: &mut FlightMetadata) {
233 let mut fix_count: u32 = 0;
234 let mut valid_fix_count: u32 = 0;
235 let mut first_valid_fix_time: Option<String> = None;
236 let mut last_valid_fix_time: Option<String> = None;
237
238 let mut lats: Vec<f64> = Vec::new();
239 let mut lons: Vec<f64> = Vec::new();
240 let mut pressure_alts: Vec<i32> = Vec::new();
241 let mut gps_alts: Vec<i32> = Vec::new();
242 let mut pressure_alt_all_non_zero = true;
243
244 let mut first_lat: Option<f64> = None;
245 let mut first_lon: Option<f64> = None;
246 let mut last_lat: Option<f64> = None;
247 let mut last_lon: Option<f64> = None;
248
249 for raw_line in text.lines() {
250 let line = raw_line.trim_end_matches('\r');
251 if line.is_empty() {
252 continue;
253 }
254 let bytes = line.as_bytes();
255
256 match bytes.first() {
257 Some(b'A') if meta.device_id.is_none() && line.len() >= 7 => {
258 meta.device_id = Some(line[1..7].trim().to_string());
260 }
261 Some(b'H') => {
262 parse_h_record(line, meta);
263 }
264 Some(b'B') if line.len() >= 35 => {
265 fix_count += 1;
266
267 let time_str = &line[1..7]; let valid = bytes[24] == b'A';
269 if valid {
270 valid_fix_count += 1;
271 if first_valid_fix_time.is_none() {
272 first_valid_fix_time = Some(time_str.to_string());
273 }
274 last_valid_fix_time = Some(time_str.to_string());
275 }
276
277 if let (Some(lat), Some(lon)) =
278 (parse_lat(&bytes[7..15]), parse_lon(&bytes[15..24]))
279 {
280 lats.push(lat);
281 lons.push(lon);
282 if first_lat.is_none() {
283 first_lat = Some(lat);
284 first_lon = Some(lon);
285 }
286 last_lat = Some(lat);
287 last_lon = Some(lon);
288 }
289
290 let pressure_alt = parse_altitude(&bytes[25..30]);
291 let gps_alt = parse_altitude(&bytes[30..35]);
292
293 match pressure_alt {
294 Some(alt) if alt != 0 => pressure_alts.push(alt),
295 _ => pressure_alt_all_non_zero = false,
296 }
297 if let Some(alt) = gps_alt {
298 gps_alts.push(alt);
299 }
300 }
301 _ => {}
302 }
303 }
304
305 meta.fix_count = Some(fix_count);
306 meta.valid_fix_count = Some(valid_fix_count);
307
308 meta.started_at = build_timestamp(
309 meta.flight_date.as_deref(),
310 first_valid_fix_time.as_deref(),
311 );
312
313 let crossed_midnight = is_midnight_crossing(
316 first_valid_fix_time.as_deref(),
317 last_valid_fix_time.as_deref(),
318 );
319 let end_date = if crossed_midnight {
320 next_day(&meta.flight_date)
321 } else {
322 meta.flight_date.clone()
323 };
324 meta.ended_at = build_timestamp(end_date.as_deref(), last_valid_fix_time.as_deref());
325
326 meta.duration_s = compute_duration_s(
327 first_valid_fix_time.as_deref(),
328 last_valid_fix_time.as_deref(),
329 );
330
331 if !lats.is_empty() {
332 meta.bbox = Some(BoundingBox {
333 min_lat: lats.iter().cloned().fold(f64::INFINITY, f64::min),
334 max_lat: lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
335 min_lon: lons.iter().cloned().fold(f64::INFINITY, f64::min),
336 max_lon: lons.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
337 });
338 }
339
340 meta.launch_lat = first_lat;
341 meta.launch_lon = first_lon;
342 meta.landing_lat = last_lat;
343 meta.landing_lon = last_lon;
344
345 let altitudes = if pressure_alt_all_non_zero && !pressure_alts.is_empty() {
346 &pressure_alts
347 } else {
348 &gps_alts
349 };
350 meta.max_alt_m = altitudes.iter().copied().max();
351 meta.min_alt_m = altitudes.iter().copied().min();
352}
353
354fn parse_h_record(line: &str, meta: &mut FlightMetadata) {
355 let upper = line.to_ascii_uppercase();
356 if upper.len() < 5 {
358 return;
359 }
360 let code = &upper[2..5];
361 match code {
362 "DTE" => {
363 if meta.flight_date.is_none() {
364 meta.flight_date = parse_hfdte(line);
365 }
366 }
367 "PLT" => {
368 if meta.pilot_name.is_none() {
369 meta.pilot_name = h_colon_value(line);
370 }
371 }
372 "GTY" => {
373 if meta.glider_type.is_none() {
374 meta.glider_type = h_colon_value(line);
375 }
376 }
377 "GID" => {
378 if meta.glider_id.is_none() {
379 meta.glider_id = h_colon_value(line);
380 }
381 }
382 _ => {}
383 }
384}
385
386fn parse_lat(bytes: &[u8]) -> Option<f64> {
388 if bytes.len() < 8 {
389 return None;
390 }
391 let dd: f64 = std::str::from_utf8(&bytes[0..2]).ok()?.parse().ok()?;
392 let mm: f64 = std::str::from_utf8(&bytes[2..4]).ok()?.parse().ok()?;
393 let mmm: f64 = std::str::from_utf8(&bytes[4..7]).ok()?.parse().ok()?;
394 let decimal = dd + (mm + mmm / 1000.0) / 60.0;
395 match bytes[7] {
396 b'S' => Some(-decimal),
397 _ => Some(decimal),
398 }
399}
400
401fn parse_lon(bytes: &[u8]) -> Option<f64> {
403 if bytes.len() < 9 {
404 return None;
405 }
406 let ddd: f64 = std::str::from_utf8(&bytes[0..3]).ok()?.parse().ok()?;
407 let mm: f64 = std::str::from_utf8(&bytes[3..5]).ok()?.parse().ok()?;
408 let mmm: f64 = std::str::from_utf8(&bytes[5..8]).ok()?.parse().ok()?;
409 let decimal = ddd + (mm + mmm / 1000.0) / 60.0;
410 match bytes[8] {
411 b'W' => Some(-decimal),
412 _ => Some(decimal),
413 }
414}
415
416fn parse_altitude(bytes: &[u8]) -> Option<i32> {
417 std::str::from_utf8(bytes).ok()?.parse().ok()
418}
419
420fn is_canonical_date(value: &str) -> bool {
421 chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok()
422}
423
424fn validate_coordinate(
425 field: &'static str,
426 value: f64,
427 min: f64,
428 max: f64,
429) -> Result<(), MetadataError> {
430 if value.is_finite() && (min..=max).contains(&value) {
431 Ok(())
432 } else {
433 Err(MetadataError::InvalidCoordinate { field, value })
434 }
435}
436
437fn parse_hfdte(line: &str) -> Option<String> {
441 let digits: String = line.chars().filter(|c| c.is_ascii_digit()).collect();
443 if digits.len() < 6 {
444 return None;
445 }
446 let d = &digits[digits.len() - 6..];
447 let dd = &d[0..2];
448 let mm = &d[2..4];
449 let yy = &d[4..6];
450 let yyyy = format!("20{yy}");
451 Some(format!("{yyyy}-{mm}-{dd}"))
452}
453
454fn h_colon_value(line: &str) -> Option<String> {
456 line.find(':')
457 .map(|i| line[i + 1..].trim().to_string())
458 .filter(|s| !s.is_empty())
459}
460
461fn build_timestamp(date: Option<&str>, time: Option<&str>) -> Option<String> {
464 match (date, time) {
465 (Some(d), Some(t)) if t.len() == 6 => {
466 let h = &t[0..2];
467 let m = &t[2..4];
468 let s = &t[4..6];
469 Some(format!("{d}T{h}:{m}:{s}Z"))
470 }
471 _ => None,
472 }
473}
474
475fn is_midnight_crossing(start: Option<&str>, end: Option<&str>) -> bool {
478 let to_secs = |t: &str| -> Option<u64> {
479 if t.len() != 6 {
480 return None;
481 }
482 let h: u64 = t[0..2].parse().ok()?;
483 let m: u64 = t[2..4].parse().ok()?;
484 let s: u64 = t[4..6].parse().ok()?;
485 Some(h * 3600 + m * 60 + s)
486 };
487 match (start.and_then(to_secs), end.and_then(to_secs)) {
488 (Some(ss), Some(es)) => es < ss,
489 _ => false,
490 }
491}
492
493fn next_day(date: &Option<String>) -> Option<String> {
496 use chrono::NaiveDate;
497 let d = date.as_deref()?;
498 let parsed = NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()?;
499 Some(parsed.succ_opt()?.format("%Y-%m-%d").to_string())
500}
501
502fn compute_duration_s(start: Option<&str>, end: Option<&str>) -> Option<u64> {
504 let (start, end) = (start?, end?);
505 if start.len() != 6 || end.len() != 6 {
506 return None;
507 }
508 let to_secs = |t: &str| -> Option<u64> {
509 let h: u64 = t[0..2].parse().ok()?;
510 let m: u64 = t[2..4].parse().ok()?;
511 let s: u64 = t[4..6].parse().ok()?;
512 Some(h * 3600 + m * 60 + s)
513 };
514 let ss = to_secs(start)?;
515 let es = to_secs(end)?;
516 if es >= ss {
517 Some(es - ss)
518 } else {
519 Some(es + 86400 - ss) }
521}
522
523#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::id::{Blake3Hex, NodeIdHex};
529
530 const MINIMAL_IGC: &str = "\
537AXXX001 TestDevice\r\n\
538HFDTE020714\r\n\
539HFPLTPILOTINCHARGE:Jane Doe\r\n\
540HFGTYGLIDERTYPE:Advance Sigma 10\r\n\
541HFGIDGLIDERID:HB-1234\r\n\
542B1200004728000N00836000EV0010001000\r\n\
543B1300004730000N00837000EA0030003000\r\n\
544B1400004732000N00838000EA0150001500\r\n\
545";
546
547 fn fake_hash() -> Blake3Hex {
548 Blake3Hex::parse(
549 "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
550 )
551 .unwrap()
552 }
553
554 fn fake_node_id() -> NodeIdHex {
555 NodeIdHex::parse(
556 "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd",
557 )
558 .unwrap()
559 }
560
561 #[test]
562 fn from_igc_bytes_populates_fields() {
563 let meta = FlightMetadata::from_igc_bytes(
564 MINIMAL_IGC.as_bytes(),
565 fake_hash(),
566 Some("test.igc"),
567 Some(fake_node_id()),
568 );
569 assert_eq!(meta.schema, "igc-net/metadata");
570 assert_eq!(meta.schema_version, 1);
571 assert_eq!(meta.igc_hash, fake_hash());
572 assert_eq!(meta.original_filename.as_deref(), Some("test.igc"));
573 assert_eq!(meta.flight_date.as_deref(), Some("2014-07-02"));
574 assert_eq!(meta.pilot_name.as_deref(), Some("Jane Doe"));
575 assert_eq!(meta.glider_type.as_deref(), Some("Advance Sigma 10"));
576 assert_eq!(meta.glider_id.as_deref(), Some("HB-1234"));
577 assert_eq!(meta.fix_count, Some(3));
578 assert_eq!(meta.valid_fix_count, Some(2)); assert_eq!(meta.launch_lat, Some(47.46666666666667));
580 assert_eq!(meta.launch_lon, Some(8.6));
581 assert_eq!(meta.landing_lat, Some(47.53333333333333));
582 assert_eq!(meta.landing_lon, Some(8.633333333333333));
583 assert_eq!(meta.max_alt_m, Some(1500));
584 assert_eq!(meta.min_alt_m, Some(100));
585 assert!(meta.bbox.is_some());
586 assert_eq!(
587 meta.published_at.as_deref(),
588 meta.published_at
589 .as_deref()
590 .filter(|value| is_canonical_utc_timestamp(value))
591 );
592 }
593
594 #[test]
595 fn to_blob_bytes_round_trip() {
596 let meta = FlightMetadata::new(fake_hash());
597 let bytes = meta.to_blob_bytes().unwrap();
598 let parsed: FlightMetadata = serde_json::from_slice(&bytes).unwrap();
599 assert_eq!(parsed.igc_hash, fake_hash());
600 }
601
602 #[test]
603 fn null_omission_no_null_in_json() {
604 let meta = FlightMetadata::new(fake_hash());
605 let json = String::from_utf8(meta.to_blob_bytes().unwrap()).unwrap();
606 assert!(
607 !json.contains("null"),
608 "JSON must not contain 'null': {json}"
609 );
610 }
611
612 #[test]
613 fn validate_rejects_wrong_schema() {
614 let mut meta = FlightMetadata::new(fake_hash());
615 meta.schema = "wrong".to_string();
616 assert!(matches!(
617 meta.validate(),
618 Err(MetadataError::WrongSchema(_))
619 ));
620 }
621
622 #[test]
623 fn validate_rejects_wrong_version() {
624 let mut meta = FlightMetadata::new(fake_hash());
625 meta.schema_version = 99;
626 assert!(matches!(
627 meta.validate(),
628 Err(MetadataError::WrongVersion(99))
629 ));
630 }
631
632 #[test]
633 fn deserialize_rejects_malformed_hash() {
634 let json = r#"{"schema":"igc-net/metadata","schema_version":1,"igc_hash":"not-a-hash"}"#;
635 assert!(serde_json::from_str::<FlightMetadata>(json).is_err());
636 }
637
638 #[test]
639 fn deserialize_rejects_uppercase_hash() {
640 let json = r#"{"schema":"igc-net/metadata","schema_version":1,"igc_hash":"ABCD1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}"#;
641 assert!(serde_json::from_str::<FlightMetadata>(json).is_err());
642 }
643
644 #[test]
645 fn validate_rejects_non_canonical_timestamp() {
646 let mut meta = FlightMetadata::new(fake_hash());
647 meta.published_at = Some("2026-03-29T18:07:55+00:00".to_string());
648 assert!(matches!(
649 meta.validate(),
650 Err(MetadataError::MalformedTimestamp {
651 field: "published_at",
652 ..
653 })
654 ));
655 }
656
657 #[test]
658 fn validate_rejects_invalid_flight_date() {
659 let mut meta = FlightMetadata::new(fake_hash());
660 meta.flight_date = Some("2026-02-31".to_string());
661 assert!(matches!(
662 meta.validate(),
663 Err(MetadataError::MalformedDate {
664 field: "flight_date",
665 ..
666 })
667 ));
668 }
669
670 #[test]
671 fn validate_rejects_out_of_range_coordinates() {
672 let mut meta = FlightMetadata::new(fake_hash());
673 meta.launch_lat = Some(91.0);
674 assert!(matches!(
675 meta.validate(),
676 Err(MetadataError::InvalidCoordinate {
677 field: "launch_lat",
678 ..
679 })
680 ));
681 }
682
683 #[test]
684 fn validate_rejects_invalid_bbox_bounds() {
685 let mut meta = FlightMetadata::new(fake_hash());
686 meta.bbox = Some(BoundingBox {
687 min_lat: 10.0,
688 max_lat: 5.0,
689 min_lon: 20.0,
690 max_lon: 25.0,
691 });
692 assert!(matches!(
693 meta.validate(),
694 Err(MetadataError::InvalidBounds { field: "bbox", .. })
695 ));
696 }
697
698 #[test]
699 fn validate_accepts_valid_metadata() {
700 let meta = FlightMetadata::new(fake_hash());
701 assert!(meta.validate().is_ok());
702 }
703
704 #[test]
705 fn midnight_crossing_duration() {
706 let d = compute_duration_s(Some("235900"), Some("000100"));
707 assert_eq!(d, Some(120));
708 }
709
710 #[test]
711 fn midnight_crossing_ended_at_uses_next_day() {
712 let igc = "\
714AXXX001 TestDevice\r\n\
715HFDTE310714\r\n\
716B2350004730000N00837000EA0030003000\r\n\
717B0010004732000N00838000EA0050005000\r\n\
718";
719 let meta =
720 FlightMetadata::from_igc_bytes(
721 igc.as_bytes(),
722 fake_hash(),
723 None,
724 Some(NodeIdHex::parse("cc".repeat(32)).unwrap()),
725 );
726 assert_eq!(meta.flight_date.as_deref(), Some("2014-07-31"));
727 assert_eq!(meta.started_at.as_deref(), Some("2014-07-31T23:50:00Z"));
728 assert_eq!(meta.ended_at.as_deref(), Some("2014-08-01T00:10:00Z"));
729 assert_eq!(meta.duration_s, Some(1200));
730 }
731
732 #[test]
733 fn normal_flight_ended_at_uses_same_day() {
734 let igc = "\
735AXXX001 TestDevice\r\n\
736HFDTE020714\r\n\
737B1200004730000N00837000EA0030003000\r\n\
738B1400004732000N00838000EA0050005000\r\n\
739";
740 let meta =
741 FlightMetadata::from_igc_bytes(
742 igc.as_bytes(),
743 fake_hash(),
744 None,
745 Some(NodeIdHex::parse("cc".repeat(32)).unwrap()),
746 );
747 assert_eq!(meta.started_at.as_deref(), Some("2014-07-02T12:00:00Z"));
748 assert_eq!(meta.ended_at.as_deref(), Some("2014-07-02T14:00:00Z"));
749 }
750
751 #[test]
752 fn next_day_advances_correctly() {
753 assert_eq!(
754 next_day(&Some("2014-07-31".to_string())),
755 Some("2014-08-01".to_string())
756 );
757 assert_eq!(
758 next_day(&Some("2024-12-31".to_string())),
759 Some("2025-01-01".to_string())
760 );
761 assert_eq!(next_day(&None), None);
762 }
763}