//! Round-trip our encoder's output through an *independent* FIT decoder. //! //! This is the strongest correctness check available without an actual upload. //! `fitparser` is a third-party, widely-used FIT reader carrying its own copy //! of the Garmin profile; it knows nothing about this crate. If it can decode //! our file, validate both CRCs, name every message and field correctly, and //! recover the physical quantities we put in, then the encoder agrees with an //! outside party about what the FIT specification says — including all the //! field numbers, scale factors and the epoch. use bikecontrol_fit::rawlog::{LogEntry, RawLog, Sample, SessionStart}; use bikecontrol_fit::{encode_activity, timestamp}; use chrono::{TimeZone, Utc}; use fitparser::profile::MesgNum; use fitparser::{FitDataRecord, Value}; const START_UNIX_MS: i64 = 1_785_931_200_000; // 2026-08-05T00:00:00Z fn session_start() -> SessionStart { SessionStart { start_unix_ms: START_UNIX_MS, utc_offset_secs: 7200, product_name: "BikeControl".to_string(), software_version: 100, serial_number: 424_242, ..Default::default() } } /// A ride with every optional stream present: power, cadence, heart rate, /// resistance, a climb, and a lap marker. fn rich_ride(seconds: u64) -> RawLog { let mut entries = Vec::new(); let mut distance = 0.0f64; for i in 0..seconds { let speed_kph = 30.0 + (i % 5) as f32; distance += f64::from(speed_kph) / 3.6; entries.push(LogEntry::Sample(Sample { elapsed_ms: i * 1000, power_w: Some(200 + (i % 50) as i16), cadence_rpm: Some(85.0 + (i % 10) as f32), speed_kph, distance_m: distance, gradient_pct: 4.0, elevation_gain_m: (distance * 0.04) as f32, heart_rate_bpm: Some(140 + (i % 20) as u8), resistance: Some(12), ..Default::default() })); } entries.push(LogEntry::Lap { at_ms: (seconds / 2) * 1000, from_controller: true, }); entries.push(LogEntry::End { at_ms: (seconds - 1) * 1000, }); RawLog { start: session_start(), entries, skipped_lines: 0, clean_shutdown: true, path: None, } } fn decode(bytes: &[u8]) -> Vec { // `from_bytes` validates the header CRC and the file CRC on the way // through; a checksum mistake fails here rather than silently at Strava. fitparser::from_bytes(bytes).expect("independent decoder rejected our FIT file") } fn of_kind(records: &[FitDataRecord], kind: MesgNum) -> Vec<&FitDataRecord> { records.iter().filter(|r| r.kind() == kind).collect() } fn field<'a>(rec: &'a FitDataRecord, name: &str) -> Option<&'a Value> { rec.fields() .iter() .find(|f| f.name() == name) .map(|f| f.value()) } /// Look a field up under its classic name or its `enhanced_` component. /// /// We write the classic `speed`, `altitude`, `avg_speed` and `max_speed` /// fields, which are the most widely understood. The FIT profile declares each /// of them as a *component* of its `enhanced_` counterpart, so a /// profile-aware decoder — including Strava's and Garmin's — presents the value /// under the enhanced name. Either is a correct decode of what we wrote. fn field_or_enhanced<'a>(rec: &'a FitDataRecord, name: &str) -> Option<&'a Value> { field(rec, name).or_else(|| field(rec, &format!("enhanced_{name}"))) } fn enhanced_num(rec: &FitDataRecord, name: &str) -> f64 { as_f64( field_or_enhanced(rec, name) .unwrap_or_else(|| panic!("neither {name} nor enhanced_{name} in {:?}", rec.kind())), ) } fn as_f64(v: &Value) -> f64 { match v { Value::Float64(f) => *f, Value::Float32(f) => f64::from(*f), Value::UInt8(n) => f64::from(*n), Value::UInt16(n) => f64::from(*n), Value::UInt32(n) => f64::from(*n), Value::SInt8(n) => f64::from(*n), Value::SInt16(n) => f64::from(*n), Value::SInt32(n) => f64::from(*n), Value::Enum(n) | Value::Byte(n) => f64::from(*n), Value::UInt8z(n) => f64::from(*n), Value::UInt16z(n) => f64::from(*n), Value::UInt32z(n) => f64::from(*n), Value::SInt64(n) => *n as f64, Value::UInt64(n) | Value::UInt64z(n) => *n as f64, other => panic!("expected a number, got {other:?}"), } } fn num(rec: &FitDataRecord, name: &str) -> f64 { as_f64(field(rec, name).unwrap_or_else(|| panic!("field {name} missing from {:?}", rec.kind()))) } #[test] fn an_independent_decoder_accepts_the_file() { let (bytes, summary) = encode_activity(&rich_ride(120)).unwrap(); let records = decode(&bytes); assert!(!records.is_empty()); // Every message we claimed to write is there, named correctly. assert_eq!(of_kind(&records, MesgNum::FileId).len(), 1); assert_eq!(of_kind(&records, MesgNum::DeviceInfo).len(), 1); assert_eq!(of_kind(&records, MesgNum::Record).len(), summary.records); assert_eq!(of_kind(&records, MesgNum::Lap).len(), 2); assert_eq!(of_kind(&records, MesgNum::Session).len(), 1); assert_eq!(of_kind(&records, MesgNum::Activity).len(), 1); assert_eq!(of_kind(&records, MesgNum::Event).len(), 2); } #[test] fn the_file_id_marks_it_as_an_activity() { let (bytes, _) = encode_activity(&rich_ride(30)).unwrap(); let records = decode(&bytes); let file_id = of_kind(&records, MesgNum::FileId)[0]; // fitparser resolves the enum against its own profile copy. assert_eq!( field(file_id, "type").unwrap().to_string(), "activity", "FR-8.2: the file must declare itself an activity" ); assert_eq!( field(file_id, "product_name").unwrap().to_string(), "BikeControl" ); assert!(field(file_id, "time_created").is_some()); assert_eq!(num(file_id, "serial_number"), 424_242.0); } #[test] fn timestamps_decode_to_the_right_wall_clock_instant() { // The epoch check that matters: an independent decoder, applying its own // notion of the FIT epoch, must recover the instant we encoded. If we had // used the Unix epoch this activity would decode as 1989. let (bytes, _) = encode_activity(&rich_ride(60)).unwrap(); let records = decode(&bytes); let expected_start = Utc.timestamp_millis_opt(START_UNIX_MS).unwrap(); let first_record = of_kind(&records, MesgNum::Record)[0]; match field(first_record, "timestamp").unwrap() { Value::Timestamp(ts) => { assert_eq!(ts.with_timezone(&Utc), expected_start); assert_eq!(ts.with_timezone(&Utc).format("%Y").to_string(), "2026"); } other => panic!("timestamp did not decode as a timestamp: {other:?}"), } // ...and the last record is 59 seconds later. let last = *of_kind(&records, MesgNum::Record).last().unwrap(); match field(last, "timestamp").unwrap() { Value::Timestamp(ts) => { assert_eq!( (ts.with_timezone(&Utc) - expected_start).num_seconds(), 59, "record spacing must be 1 Hz" ); } other => panic!("unexpected {other:?}"), } } #[test] fn record_fields_decode_to_the_physical_values_we_encoded() { // fitparser applies the profile's scale and offset, so these assertions // check our scaling against an outside source rather than against // ourselves. let log = RawLog { start: session_start(), entries: vec![ LogEntry::Sample(Sample { elapsed_ms: 0, power_w: Some(275), cadence_rpm: Some(93.0), speed_kph: 36.0, // 10 m/s distance_m: 0.0, gradient_pct: 0.0, heart_rate_bpm: Some(148), altitude_m: Some(250.0), ..Default::default() }), LogEntry::Sample(Sample { elapsed_ms: 1000, power_w: Some(275), cadence_rpm: Some(93.0), speed_kph: 36.0, distance_m: 1234.5, gradient_pct: -6.25, heart_rate_bpm: Some(148), altitude_m: Some(240.0), ..Default::default() }), LogEntry::End { at_ms: 1000 }, ], skipped_lines: 0, clean_shutdown: true, path: None, }; let (bytes, _) = encode_activity(&log).unwrap(); let records = decode(&bytes); let recs = of_kind(&records, MesgNum::Record); assert_eq!(recs.len(), 2); assert_eq!(num(recs[0], "power"), 275.0, "watts"); assert_eq!(num(recs[0], "cadence"), 93.0, "rpm"); assert_eq!(num(recs[0], "heart_rate"), 148.0, "bpm"); assert!( (enhanced_num(recs[0], "speed") - 10.0).abs() < 1e-6, "speed must decode as 10 m/s, got {}", enhanced_num(recs[0], "speed") ); assert!( (enhanced_num(recs[0], "altitude") - 250.0).abs() < 0.2, "altitude must decode as metres, got {}", enhanced_num(recs[0], "altitude") ); assert!( (num(recs[1], "distance") - 1234.5).abs() < 0.01, "distance must decode as metres, got {}", num(recs[1], "distance") ); assert!( (num(recs[1], "grade") + 6.25).abs() < 0.01, "grade must decode as percent, got {}", num(recs[1], "grade") ); // Units come from the decoder's profile, so they confirm we picked the // fields we think we picked. let units = |name: &str| { recs[0] .fields() .iter() .find(|f| f.name() == name || f.name() == format!("enhanced_{name}")) .unwrap_or_else(|| panic!("no field {name}")) .units() .to_string() }; assert_eq!(units("power"), "watts"); assert_eq!(units("speed"), "m/s"); assert_eq!(units("distance"), "m"); assert_eq!(units("altitude"), "m"); assert_eq!(units("heart_rate"), "bpm"); assert_eq!(units("cadence"), "rpm"); } #[test] fn session_aggregates_decode_under_the_right_names() { // The guard against the lap/session field-number divergence: if we had used // lap numbering in the session message, avg_power would decode as // max_cadence and this test would catch it. let log = rich_ride(100); let (bytes, summary) = encode_activity(&log).unwrap(); let records = decode(&bytes); let session = of_kind(&records, MesgNum::Session)[0]; assert_eq!(field(session, "sport").unwrap().to_string(), "cycling"); assert_eq!( field(session, "sub_sport").unwrap().to_string(), "virtual_activity", "FR-8.3: this is what makes Strava file it as a Virtual Ride" ); assert_eq!( num(session, "total_elapsed_time"), summary.total_elapsed_s, "seconds" ); assert_eq!(num(session, "total_timer_time"), summary.total_timer_s); assert!( (num(session, "total_distance") - summary.total_distance_m).abs() < 0.05, "metres" ); assert_eq!( num(session, "avg_power"), f64::from(summary.avg_power_w.unwrap()) ); assert_eq!( num(session, "max_power"), f64::from(summary.max_power_w.unwrap()) ); assert_eq!(num(session, "total_ascent"), f64::from(summary.total_ascent_m)); assert_eq!(num(session, "num_laps"), 2.0); assert_eq!(num(session, "first_lap_index"), 0.0); // Averages must be plausible for the ride we synthesised. let avg_cad = num(session, "avg_cadence"); assert!((85.0..=95.0).contains(&avg_cad), "avg_cadence {avg_cad}"); let max_hr = num(session, "max_heart_rate"); assert!((150.0..=165.0).contains(&max_hr), "max_heart_rate {max_hr}"); let avg_speed = enhanced_num(session, "avg_speed"); assert!((7.0..=10.0).contains(&avg_speed), "avg_speed m/s {avg_speed}"); } #[test] fn lap_aggregates_decode_under_the_right_names() { let (bytes, _) = encode_activity(&rich_ride(100)).unwrap(); let records = decode(&bytes); let laps = of_kind(&records, MesgNum::Lap); assert_eq!(laps.len(), 2, "one lap marker splits the ride in two"); for (i, lap) in laps.iter().enumerate() { assert_eq!(num(lap, "message_index"), i as f64); assert_eq!(field(lap, "sport").unwrap().to_string(), "cycling"); assert!(num(lap, "total_elapsed_time") > 0.0); assert!(num(lap, "total_distance") > 0.0); let avg_power = num(lap, "avg_power"); assert!( (200.0..=250.0).contains(&avg_power), "lap {i} avg_power decoded as {avg_power}; a lap/session field-number \ mix-up would show up here" ); let avg_cadence = num(lap, "avg_cadence"); assert!((85.0..=95.0).contains(&avg_cadence), "lap {i} avg_cadence {avg_cadence}"); } assert_eq!( field(laps[0], "lap_trigger").unwrap().to_string(), "manual", "a rider-pressed lap" ); assert_eq!( field(laps[1], "lap_trigger").unwrap().to_string(), "session_end", "the final lap is closed by the end of the session" ); // The laps must tile the session exactly. let session = of_kind(&records, MesgNum::Session)[0]; let lap_elapsed: f64 = laps.iter().map(|l| num(l, "total_elapsed_time")).sum(); assert!( (lap_elapsed - num(session, "total_elapsed_time")).abs() < 0.01, "laps sum to {lap_elapsed}, session says {}", num(session, "total_elapsed_time") ); let lap_distance: f64 = laps.iter().map(|l| num(l, "total_distance")).sum(); assert!( (lap_distance - num(session, "total_distance")).abs() < 1.0, "laps sum to {lap_distance} m, session says {} m", num(session, "total_distance") ); } #[test] fn the_activity_message_closes_the_file() { let (bytes, summary) = encode_activity(&rich_ride(45)).unwrap(); let records = decode(&bytes); let activity = of_kind(&records, MesgNum::Activity)[0]; assert_eq!(num(activity, "num_sessions"), 1.0); assert_eq!(num(activity, "total_timer_time"), summary.total_timer_s); assert_eq!(field(activity, "type").unwrap().to_string(), "manual"); assert_eq!(field(activity, "event").unwrap().to_string(), "activity"); assert_eq!(field(activity, "event_type").unwrap().to_string(), "stop"); assert!(field(activity, "local_timestamp").is_some()); // It must be the last message in the file. assert_eq!( records.last().unwrap().kind(), MesgNum::Activity, "the activity message closes an activity file" ); } #[test] fn timer_events_bracket_the_ride() { let (bytes, _) = encode_activity(&rich_ride(30)).unwrap(); let records = decode(&bytes); let events = of_kind(&records, MesgNum::Event); assert_eq!(events.len(), 2); assert_eq!(field(events[0], "event").unwrap().to_string(), "timer"); assert_eq!(field(events[0], "event_type").unwrap().to_string(), "start"); assert_eq!(field(events[1], "event_type").unwrap().to_string(), "stop_all"); } #[test] fn messages_appear_in_the_order_an_uploader_expects() { let (bytes, _) = encode_activity(&rich_ride(60)).unwrap(); let records = decode(&bytes); let kinds: Vec = records.iter().map(|r| r.kind()).collect(); assert_eq!(kinds[0], MesgNum::FileId, "file_id must come first"); let first_record = kinds.iter().position(|k| *k == MesgNum::Record).unwrap(); let first_lap = kinds.iter().position(|k| *k == MesgNum::Lap).unwrap(); let session = kinds.iter().position(|k| *k == MesgNum::Session).unwrap(); let activity = kinds.iter().position(|k| *k == MesgNum::Activity).unwrap(); assert!(first_record < first_lap, "a lap follows the records it covers"); assert!(first_lap < session, "laps precede the session"); assert!(session < activity, "the session precedes the activity"); } #[test] fn a_ride_without_a_heart_rate_strap_carries_no_heart_rate_field() { // An all-invalid stream is worse than an absent one: some importers render // it as a flat zero trace. let log = RawLog { start: session_start(), entries: vec![ LogEntry::Sample(Sample { elapsed_ms: 0, power_w: Some(200), speed_kph: 30.0, ..Default::default() }), LogEntry::Sample(Sample { elapsed_ms: 1000, power_w: Some(210), speed_kph: 30.0, distance_m: 8.3, ..Default::default() }), LogEntry::End { at_ms: 1000 }, ], skipped_lines: 0, clean_shutdown: true, path: None, }; let (bytes, _) = encode_activity(&log).unwrap(); let records = decode(&bytes); for rec in of_kind(&records, MesgNum::Record) { assert!(field(rec, "heart_rate").is_none()); assert!(field(rec, "cadence").is_none()); assert!(field(rec, "power").is_some()); } } #[test] fn a_ble_dropout_leaves_a_hole_the_decoder_can_see() { // FR-8.5: the ride survives a dropout. The records simply skip the missing // seconds, which is exactly how a Garmin device represents a sensor // disconnect — no decoder treats it as corruption. let mut entries = Vec::new(); for i in (0..10u64).chain(40..50u64) { entries.push(LogEntry::Sample(Sample { elapsed_ms: i * 1000, power_w: Some(220), speed_kph: 36.0, distance_m: (i * 10) as f64, ..Default::default() })); } entries.push(LogEntry::Gap { at_ms: 9_000, until_ms: Some(40_000), reason: "peripheral disconnected".into(), }); entries.push(LogEntry::End { at_ms: 49_000 }); let log = RawLog { start: session_start(), entries, skipped_lines: 0, clean_shutdown: true, path: None, }; let (bytes, summary) = encode_activity(&log).unwrap(); assert_eq!(summary.gaps, 1); let records = decode(&bytes); let recs = of_kind(&records, MesgNum::Record); assert_eq!(recs.len(), 20); let stamps: Vec = recs .iter() .map(|r| match field(r, "timestamp").unwrap() { Value::Timestamp(t) => t.timestamp(), other => panic!("{other:?}"), }) .collect(); let jump = stamps[10] - stamps[9]; assert_eq!(jump, 31, "the dropout shows as a 31 s hole in the records"); // The session still spans the whole ride. let session = of_kind(&records, MesgNum::Session)[0]; assert_eq!(num(session, "total_elapsed_time"), 49.0); } #[test] fn a_long_ride_encodes_and_decodes() { // An hour at 1 Hz — enough messages to exercise definition reuse, and a // realistic file size for an upload. let (bytes, summary) = encode_activity(&rich_ride(3600)).unwrap(); assert_eq!(summary.records, 3600); let records = decode(&bytes); assert_eq!(of_kind(&records, MesgNum::Record).len(), 3600); // Definition reuse: one definition for 3600 records, not 3600 of them. let bytes_per_record = bytes.len() as f64 / 3600.0; assert!( bytes_per_record < 30.0, "{bytes_per_record:.1} bytes per record suggests definitions are being \ re-emitted for every message" ); } #[test] fn the_fit_epoch_constant_agrees_with_the_decoder() { // Belt and braces: derive the epoch from a decoded file rather than // trusting our own constant. let (bytes, _) = encode_activity(&rich_ride(2)).unwrap(); let records = decode(&bytes); let first = of_kind(&records, MesgNum::Record)[0]; let decoded_unix = match field(first, "timestamp").unwrap() { Value::Timestamp(t) => t.timestamp(), other => panic!("{other:?}"), }; let encoded_fit = timestamp::from_unix_millis(START_UNIX_MS).unwrap(); assert_eq!( decoded_unix - i64::from(encoded_fit), bikecontrol_fit::FIT_EPOCH_UNIX_SECS ); }