//! 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, gear: 6, gear_count: 12, development_m: 5.7, target_cadence_rpm: 90.0, speed_source: bikecontrol_core::types::SpeedSource::Drivetrain, pedal_force_n: 120.0, 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); }