//! Golden test: the exact bytes of a small synthetic activity. //! //! The round-trip tests prove an independent decoder agrees with us about what //! the file *means*. This one pins what the file *is*, byte for byte, so that //! any change to the encoder — a reordered field, a different scale, an extra //! message — has to be made deliberately and reviewed as a diff of the golden //! constant rather than slipping through unnoticed. //! //! Regenerate with `PRINT_GOLDEN=1 cargo test -p bikecontrol-fit --test golden` //! and paste the printed constant back in, *after* checking the change is one //! you meant to make. use bikecontrol_fit::encode::{file_header, verify}; use bikecontrol_fit::rawlog::{LogEntry, RawLog, Sample, SessionStart}; use bikecontrol_fit::{crc16, encode_activity, timestamp}; /// 2026-08-05T10:00:00Z. const START_UNIX_MS: i64 = 1_785_967_200_000; /// A three-second ride, chosen to be small enough to read in hex and rich /// enough to exercise every message type. fn tiny_ride() -> RawLog { let samples = [ // (elapsed_ms, power, cadence, speed_kph, distance_m, grade) (0u64, 210i16, 90.0f32, 30.0f32, 0.0f64, 0.0f32), (1000, 220, 91.0, 30.6, 8.5, 1.0), (2000, 230, 92.0, 31.2, 17.2, 2.0), ]; let mut entries: Vec = samples .iter() .map(|&(e, p, c, v, d, g)| { LogEntry::Sample(Sample { elapsed_ms: e, power_w: Some(p), cadence_rpm: Some(c), speed_kph: v, distance_m: d, gradient_pct: g, ..Default::default() }) }) .collect(); entries.push(LogEntry::End { at_ms: 2000 }); RawLog { start: SessionStart { start_unix_ms: START_UNIX_MS, utc_offset_secs: 0, product_name: "BikeControl".to_string(), software_version: 100, serial_number: 1, ..Default::default() }, entries, skipped_lines: 0, clean_shutdown: true, path: None, } } fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn unhex(s: &str) -> Vec { s.as_bytes() .chunks(2) .map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()) .collect() } /// The complete encoding of [`tiny_ride`]. const GOLDEN: &str = include_str!("golden/tiny_ride.hex"); #[test] fn the_encoding_of_a_tiny_ride_is_stable() { let (bytes, _) = encode_activity(&tiny_ride()).unwrap(); if std::env::var("PRINT_GOLDEN").is_ok() { println!("{}", hex(&bytes)); } let expected = unhex(GOLDEN.trim()); assert_eq!( hex(&bytes), hex(&expected), "the encoder's output changed; if that was intended, regenerate the \ golden with PRINT_GOLDEN=1 after reviewing the diff" ); } #[test] fn the_golden_file_is_internally_consistent() { // The three checks an uploader makes before it looks at anything else. let bytes = unhex(GOLDEN.trim()); assert!(verify(&bytes).is_ok(), "{:?}", verify(&bytes)); // Header, spelled out rather than delegated. assert_eq!(bytes[0], 14, "header size"); assert_eq!(bytes[1], 0x20, "protocol version 2.0"); assert_eq!(&bytes[8..12], b".FIT", "signature"); let declared = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; assert_eq!( declared, bytes.len() - 16, "header data size must equal the bytes between the header and the CRC" ); let header_crc = u16::from_le_bytes([bytes[12], bytes[13]]); assert_eq!(header_crc, crc16(&bytes[0..12]), "header CRC"); let file_crc = u16::from_le_bytes([bytes[bytes.len() - 2], bytes[bytes.len() - 1]]); assert_eq!( file_crc, crc16(&bytes[..bytes.len() - 2]), "file CRC over header plus data" ); // And the header we produce independently for that data size must match // the one embedded in the golden. assert_eq!(&bytes[0..14], &file_header(declared as u32)); } #[test] fn the_first_message_is_a_file_id_definition_with_the_expected_bytes() { let bytes = unhex(GOLDEN.trim()); let data = &bytes[14..]; // Definition message for file_id (global 0), local type 0. assert_eq!(data[0], 0x40, "normal header, definition bit set, local 0"); assert_eq!(data[1], 0x00, "reserved"); assert_eq!(data[2], 0x00, "little-endian architecture"); assert_eq!(&data[3..5], &[0x00, 0x00], "global message number 0 = file_id"); let n_fields = data[5] as usize; assert_eq!(n_fields, 6, "type, manufacturer, product, serial, time, name"); // Field 0 (type) is a one-byte enum; the first data message must set it to // 4 (activity). This is the single most important byte in the file. assert_eq!(&data[6..9], &[0x00, 0x01, 0x00], "field 0: 1 byte, enum"); let data_msg = 6 + n_fields * 3; assert_eq!(data[data_msg], 0x00, "normal header, data, local 0"); assert_eq!( data[data_msg + 1], 4, "file_id.type must be 4 (activity), or nothing will import it" ); // manufacturer 255 (development), little endian. assert_eq!(&data[data_msg + 2..data_msg + 4], &[0xFF, 0x00]); } #[test] fn the_golden_timestamp_is_the_fit_epoch_not_the_unix_epoch() { let bytes = unhex(GOLDEN.trim()); let expected = timestamp::from_unix_millis(START_UNIX_MS).unwrap(); // file_id.time_created sits at a known offset: 14 (header) + 6 (definition // header) + 18 (six field definitions) + 1 (data header) + 1 (type) // + 2 (manufacturer) + 2 (product) + 4 (serial) = 48. let at = 14 + 6 + 6 * 3 + 1 + 1 + 2 + 2 + 4; let encoded = u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]); assert_eq!(encoded, expected); // Spelled out: the value must be the Unix time minus the FIT epoch... assert_eq!( i64::from(encoded), START_UNIX_MS / 1000 - bikecontrol_fit::FIT_EPOCH_UNIX_SECS ); // ...and emphatically not the Unix time itself. assert_ne!(i64::from(encoded), START_UNIX_MS / 1000); // A 1989 date would decode below this bound. assert!(encoded > 1_100_000_000, "would render as the 1990s"); } #[test] fn the_golden_still_decodes_with_an_independent_parser() { // Ties the two test strategies together: the pinned bytes are not just // stable, they are still a valid FIT file. let bytes = unhex(GOLDEN.trim()); let records = fitparser::from_bytes(&bytes).expect("golden bytes must still parse"); let kinds: Vec = records.iter().map(|r| format!("{:?}", r.kind())).collect(); assert_eq!(kinds, vec![ "FileId", "DeviceInfo", "Event", "Record", "Record", "Record", "Lap", "Event", "Session", "Activity", ]); } #[test] fn definitions_are_emitted_once_each() { // Seven message types, seven definition messages, no matter how many // records. If this number grows, the definition cache has broken and every // record is carrying its own schema. let bytes = unhex(GOLDEN.trim()); let definitions = count_definitions(&bytes); assert_eq!(definitions, 7, "file_id, device_info, event, record, lap, session, activity"); // The same holds for a much longer ride. let mut log = tiny_ride(); log.entries.clear(); for i in 0..600u64 { log.entries.push(LogEntry::Sample(Sample { elapsed_ms: i * 1000, power_w: Some(200), cadence_rpm: Some(90.0), speed_kph: 30.0, distance_m: (i as f64) * 8.33, ..Default::default() })); } log.entries.push(LogEntry::End { at_ms: 599_000 }); let (long_bytes, _) = encode_activity(&log).unwrap(); assert_eq!(count_definitions(&long_bytes), 7); } /// Walk the data-records section and count definition messages. /// /// A deliberately independent re-implementation of the record framing: if the /// encoder and this walker disagree about message sizes, the walk desynchronises /// and the count comes out wrong. fn count_definitions(bytes: &[u8]) -> usize { let data = &bytes[14..bytes.len() - 2]; // local message type -> total data-message body size let mut sizes = [0usize; 16]; let mut i = 0; let mut definitions = 0; while i < data.len() { let header = data[i]; assert_eq!(header & 0x80, 0, "we never emit compressed timestamp headers"); let local = (header & 0x0F) as usize; if header & 0x40 != 0 { definitions += 1; let n = data[i + 5] as usize; let mut total = 0; for f in 0..n { total += data[i + 6 + f * 3 + 1] as usize; } sizes[local] = total; i += 6 + n * 3; } else { assert!(sizes[local] > 0, "data message before its definition"); i += 1 + sizes[local]; } } assert_eq!(i, data.len(), "message framing did not land exactly on the end"); definitions } #[test] fn a_record_costs_a_fixed_and_small_number_of_bytes() { // A tripwire on file size. The fixed cost is the seven definitions plus the // summary messages; the marginal cost is one record. If the marginal cost // jumps, a definition is being re-emitted per sample and an hour-long ride // will produce a file uploaders throttle or reject. let baseline = unhex(GOLDEN.trim()).len(); let mut log = tiny_ride(); let extra = 100u64; log.entries.clear(); for i in 0..(3 + extra) { log.entries.push(LogEntry::Sample(Sample { elapsed_ms: i * 1000, power_w: Some(210), cadence_rpm: Some(90.0), speed_kph: 30.0, distance_m: (i as f64) * 8.5, ..Default::default() })); } log.entries.push(LogEntry::End { at_ms: (2 + extra) * 1000, }); let (bigger, _) = encode_activity(&log).unwrap(); let per_record = (bigger.len() - baseline) as f64 / extra as f64; assert!( (17.0..=19.0).contains(&per_record), "expected ~18 bytes per record (1 header + timestamp, altitude, \ distance, speed, grade, power, cadence), got {per_record:.1}" ); // An hour at 1 Hz must stay comfortably within what an uploader accepts. let hour = baseline as f64 + 3600.0 * per_record; assert!(hour < 100_000.0, "an hour would be {hour:.0} bytes"); }