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);
|
||||
}
|
||||
Reference in New Issue
Block a user