Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
10 KiB
Rust
295 lines
10 KiB
Rust
//! 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<LogEntry> = 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<u8> {
|
|
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<String> = 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");
|
|
}
|