Speed now comes from the drivetrain and the load from the road, which is the way round a bike actually works. Speed is cadence x development, filtered lightly. Power, not cadence, decides whether the rider is driving it: on a direct-drive trainer the flywheel keeps the cranks turning after they stop, so cadence alone reads a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs down to whatever the gradient sustains on no power - zero uphill, a real freewheeling speed on a descent. Stopping on a 3.5% climb used to settle at 22 km/h and stay there, because the model wanted to decelerate and a blend toward the flywheel speed outvoted it; that blend is gone. The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred from wheel speed, which one sprocket and no freewheel make exact. Its Zwift channel does carry cadence, and is now greeted with RideOn and subscribed on every notifying characteristic, so a measured value is used where one arrives. The load is commanded as power, not gradient. The trainer declares 50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing negatives, and whether it acts on 0x11 at all is still unconfirmed. Its power target is a ceiling rather than a setpoint, which is very nearly what a road is: exceed it and the surplus becomes speed. Gravity travels on the same channel as watts, so nothing is lost by leaving 0x11 alone. LoadChannel keeps the gradient path selectable and tested. Virtual shifting reaches the trainer for the first time. The physics load model was written but never called, and a paddle press both shifted a gear in Rust and nudged the gradient in the webview - the shift silently, the tilt visibly, so the paddles looked like a gradient trim. Also: a fixed 12 W drivetrain loss, held as a power because that is how it presents; crank length, so a gear can be reported as the force it puts under the foot; gear and pedal force on the ride screen; a drag-race profile for testing gearing on the flat. Two readout bugs fixed on the way. The rolling windows were trimmed by timestamp but fed on a fixed timer, so every second spent on the ride screen before starting pushed samples at t=0 that could never expire - speed read a fraction of the truth for the first 45 s. And the headline speed was a 45 s mean, which took most of a minute to show a gear change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
269 lines
8.9 KiB
Rust
269 lines
8.9 KiB
Rust
//! 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);
|
|
}
|