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:
2026-08-05 13:50:01 +02:00
co-authored by Claude Opus 5
parent 7c17ca6158
commit 3a2a787b7d
23 changed files with 3297 additions and 46 deletions
+262
View File
@@ -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);
}