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>
543 lines
20 KiB
Rust
543 lines
20 KiB
Rust
//! 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
|
|
);
|
|
}
|