Add Svelte GUI, FIT encoder and README
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>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
//! End-to-end crash safety (FR-8.4, FR-8.5, FR-8.6).
|
||||
//!
|
||||
//! These tests do to the journal what a crash does — truncate it at an
|
||||
//! arbitrary byte — and then check that a valid activity still comes out the
|
||||
//! other side.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bikecontrol_core::{ControlMode, RideSnapshot, Telemetry};
|
||||
use bikecontrol_fit::{build_fit_from_log, read_log, verify, Recorder, RecorderOptions};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fitparser::profile::MesgNum;
|
||||
|
||||
fn workdir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bikecontrol-fit-it-{name}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn snapshot(second: u64) -> RideSnapshot {
|
||||
let elapsed_ms = second * 1000;
|
||||
RideSnapshot {
|
||||
elapsed_ms,
|
||||
telemetry: Telemetry {
|
||||
elapsed_ms,
|
||||
power_w: Some(180 + (second % 60) as i16),
|
||||
cadence_rpm: Some(84.0 + (second % 12) as f32),
|
||||
heart_rate_bpm: Some(138 + (second % 25) as u8),
|
||||
..Default::default()
|
||||
},
|
||||
virtual_speed_kph: 29.0 + (second % 7) as f32,
|
||||
virtual_distance_m: second as f64 * 8.2,
|
||||
gradient_pct: ((second % 20) as f32 - 10.0) / 2.0,
|
||||
elevation_gain_m: second as f32 * 0.15,
|
||||
mode: ControlMode::Profile,
|
||||
target: None,
|
||||
profile_progress: Some(second as f32 / 600.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `seconds` of riding and leave the journal behind, as a crash would.
|
||||
fn crashed_journal(dir: &Path, seconds: u64) -> PathBuf {
|
||||
let log = dir.join("ride.jsonl");
|
||||
let mut rec = Recorder::create_at(
|
||||
&log,
|
||||
RecorderOptions::default(),
|
||||
Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
|
||||
7200,
|
||||
)
|
||||
.unwrap();
|
||||
for s in 0..seconds {
|
||||
rec.record(&snapshot(s)).unwrap();
|
||||
}
|
||||
// No finish(): the process "dies" here.
|
||||
rec.abandon()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_journal_truncated_mid_line_still_yields_a_valid_activity() {
|
||||
let dir = workdir("torn");
|
||||
let log = crashed_journal(&dir, 120);
|
||||
|
||||
// Chop the file part-way through the final line, exactly as a power cut
|
||||
// during a write would.
|
||||
let text = fs::read_to_string(&log).unwrap();
|
||||
let final_terminator = text.rfind('\n').unwrap();
|
||||
let start_of_final_line = text[..final_terminator].rfind('\n').unwrap() + 1;
|
||||
let torn = &text[..start_of_final_line + 12];
|
||||
assert!(!torn.ends_with('\n'), "the cut must land inside a line");
|
||||
fs::write(&log, torn).unwrap();
|
||||
|
||||
let parsed = read_log(&log).unwrap();
|
||||
assert_eq!(parsed.skipped_lines, 1, "exactly the torn line was dropped");
|
||||
assert_eq!(parsed.samples().count(), 119, "one sample lost, not the ride");
|
||||
assert!(!parsed.clean_shutdown);
|
||||
|
||||
let fit = dir.join("recovered.fit");
|
||||
let summary = build_fit_from_log(&log, &fit).unwrap();
|
||||
assert_eq!(summary.records, 119);
|
||||
assert!(summary.recovered_from_crash);
|
||||
|
||||
let bytes = fs::read(&fit).unwrap();
|
||||
assert!(verify(&bytes).is_ok(), "{:?}", verify(&bytes));
|
||||
let records = fitparser::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
records.iter().filter(|r| r.kind() == MesgNum::Record).count(),
|
||||
119
|
||||
);
|
||||
assert_eq!(
|
||||
records.iter().filter(|r| r.kind() == MesgNum::Session).count(),
|
||||
1
|
||||
);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_journal_truncated_at_any_byte_never_produces_a_broken_fit() {
|
||||
// The general statement. For every truncation point past the header, the
|
||||
// recovery path must either refuse cleanly or produce a file that verifies
|
||||
// — never a file that passes our checks and fails someone else's.
|
||||
let dir = workdir("everytrunc");
|
||||
let log = crashed_journal(&dir, 40);
|
||||
let full = fs::read(&log).unwrap();
|
||||
|
||||
let truncated_log = dir.join("truncated.jsonl");
|
||||
let mut produced = 0;
|
||||
for cut in (16..full.len()).step_by(7) {
|
||||
fs::write(&truncated_log, &full[..cut]).unwrap();
|
||||
let fit = dir.join("out.fit");
|
||||
// Refusing is fine: a journal with no header, or with no samples yet,
|
||||
// has no activity in it. Producing a *broken* file is not.
|
||||
if build_fit_from_log(&truncated_log, &fit).is_ok() {
|
||||
let bytes = fs::read(&fit).unwrap();
|
||||
assert!(
|
||||
verify(&bytes).is_ok(),
|
||||
"truncation at {cut} produced an invalid FIT: {:?}",
|
||||
verify(&bytes)
|
||||
);
|
||||
fitparser::from_bytes(&bytes).unwrap_or_else(|e| {
|
||||
panic!("truncation at {cut} produced a file the decoder rejected: {e}")
|
||||
});
|
||||
produced += 1;
|
||||
}
|
||||
}
|
||||
assert!(produced > 10, "expected most truncations to be recoverable");
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_reproduces_the_file_a_clean_finish_would_have_written() {
|
||||
let dir = workdir("identical");
|
||||
let log = dir.join("ride.jsonl");
|
||||
let mut rec = Recorder::create_at(
|
||||
&log,
|
||||
RecorderOptions::default(),
|
||||
Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
|
||||
7200,
|
||||
)
|
||||
.unwrap();
|
||||
for s in 0..90 {
|
||||
rec.record(&snapshot(s)).unwrap();
|
||||
}
|
||||
rec.mark_lap(30_000, true).unwrap();
|
||||
rec.mark_gap(45_000, "trainer dropped").unwrap();
|
||||
rec.record(&snapshot(60)).unwrap();
|
||||
|
||||
let clean = dir.join("clean.fit");
|
||||
let clean_summary = rec.finish(&clean).unwrap();
|
||||
|
||||
let rebuilt = dir.join("rebuilt.fit");
|
||||
let rebuilt_summary = build_fit_from_log(&log, &rebuilt).unwrap();
|
||||
|
||||
assert_eq!(clean_summary, rebuilt_summary);
|
||||
assert_eq!(fs::read(&clean).unwrap(), fs::read(&rebuilt).unwrap());
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ride_survives_repeated_dropouts_and_laps() {
|
||||
// FR-8.5 plus FR-8.7 together, over a ride that keeps losing the trainer.
|
||||
let dir = workdir("messy");
|
||||
let log = dir.join("ride.jsonl");
|
||||
let mut rec = Recorder::create_at(
|
||||
&log,
|
||||
RecorderOptions::default(),
|
||||
Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut second = 0u64;
|
||||
for round in 0..4 {
|
||||
for _ in 0..30 {
|
||||
rec.record(&snapshot(second)).unwrap();
|
||||
second += 1;
|
||||
}
|
||||
rec.mark_lap(second * 1000, round % 2 == 0).unwrap();
|
||||
rec.mark_gap(second * 1000, "peripheral disconnected").unwrap();
|
||||
second += 15; // fifteen seconds of silence
|
||||
}
|
||||
for _ in 0..30 {
|
||||
rec.record(&snapshot(second)).unwrap();
|
||||
second += 1;
|
||||
}
|
||||
|
||||
let fit = dir.join("ride.fit");
|
||||
let summary = rec.finish(&fit).unwrap();
|
||||
|
||||
assert_eq!(summary.records, 150, "every sample we actually saw");
|
||||
assert_eq!(summary.laps, 5, "four markers split the ride into five laps");
|
||||
assert_eq!(summary.gaps, 4);
|
||||
assert!(!summary.recovered_from_crash);
|
||||
|
||||
let bytes = fs::read(&fit).unwrap();
|
||||
assert!(verify(&bytes).is_ok());
|
||||
let records = fitparser::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(records.iter().filter(|r| r.kind() == MesgNum::Lap).count(), 5);
|
||||
assert_eq!(
|
||||
records.iter().filter(|r| r.kind() == MesgNum::Record).count(),
|
||||
150
|
||||
);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_journal_with_only_a_header_is_refused_rather_than_written_empty() {
|
||||
let dir = workdir("empty");
|
||||
let log = crashed_journal(&dir, 0);
|
||||
let fit = dir.join("out.fit");
|
||||
assert!(
|
||||
build_fit_from_log(&log, &fit).is_err(),
|
||||
"an activity with no records is rejected by every uploader"
|
||||
);
|
||||
assert!(!fit.exists(), "no file should be left behind");
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_ride_records_and_recovers() {
|
||||
// Two hours at 1 Hz — the realistic worst case for a trainer session.
|
||||
let dir = workdir("long");
|
||||
let log = dir.join("ride.jsonl");
|
||||
let mut rec = Recorder::create_at(
|
||||
&log,
|
||||
RecorderOptions {
|
||||
// fsync per sample would dominate the runtime and proves nothing
|
||||
// extra here; durability is covered by its own test.
|
||||
fsync_every: None,
|
||||
..Default::default()
|
||||
},
|
||||
Utc.with_ymd_and_hms(2026, 8, 5, 6, 30, 0).unwrap(),
|
||||
7200,
|
||||
)
|
||||
.unwrap();
|
||||
for s in 0..7200 {
|
||||
rec.record(&snapshot(s)).unwrap();
|
||||
}
|
||||
let fit = dir.join("ride.fit");
|
||||
let summary = rec.finish(&fit).unwrap();
|
||||
|
||||
assert_eq!(summary.records, 7200);
|
||||
assert_eq!(summary.total_elapsed_s, 7199.0);
|
||||
let bytes = fs::read(&fit).unwrap();
|
||||
assert!(verify(&bytes).is_ok());
|
||||
assert!(
|
||||
bytes.len() < 200_000,
|
||||
"two hours came to {} bytes",
|
||||
bytes.len()
|
||||
);
|
||||
let records = fitparser::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
records.iter().filter(|r| r.kind() == MesgNum::Record).count(),
|
||||
7200
|
||||
);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0e20d552f90100002e464954b2b140000000000600010001028402028403048c040486080c070004ff000100010000006066d64442696b65436f6e74726f6c00410000170007fd04860001020202840402840502841901001b0c07016066d64400ff00010064000542696b65436f6e74726f6c00420000150004fd0486000100010100040102026066d644000000430000140007fd0486020284050486060284090283070284040102036066d644c409000000008d200000d2005a036166d644c4095203000034216400dc005b036266d644c509b8060000db21c800e6005c440000130016fe0284fd04860001000101000204860704860804860904860d02840e02841101021201021302841402841502841602841701001801001901002701002904862d02830400006266d64409016066d644d0070000d0070000b80600009821db215b5cdc00e600000000000007023ac20100006400026266d644000400450000120016fe0284fd04860001000101000204860501000601000704860804860904860e02840f02841201021301021402841502841602841702841902841a02841c01003004860500006266d64408016066d644023ad0070000d0070000b80600009821db215b5cdc00e600000000000000010000c2010000460000220007fd0486000486010284020100030100040100050486066266d644d00700000100001a016266d6441a4b
|
||||
@@ -0,0 +1,542 @@
|
||||
//! 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<FitDataRecord> {
|
||||
// `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<MesgNum> = 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<i64> = 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user