From 635802a4d8a9edbf85f6d15a8e16055455c8fe33 Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Mon, 2 Nov 2026 15:29:47 -0500 Subject: [PATCH 1/2] Test the local idle deadline and bound import thinning Silence from an unplugged receiver must end a track through the deadline in run(), which nothing tested. Import thinning had no lower bound, so keeping only the ends passed. Also updates a test label now that a crossing inside road_heading is settled as known. Story: CHOMP-3 Story: CHOMP-5 Co-Authored-By: Claude Opus 5.5 --- crates/carchompd/src/api.rs | 6 ++++ crates/carchompd/src/recorder.rs | 62 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/carchompd/src/api.rs b/crates/carchompd/src/api.rs index 7b0ea48..c07b1cc 100644 --- a/crates/carchompd/src/api.rs +++ b/crates/carchompd/src/api.rs @@ -1173,6 +1173,12 @@ mod tests { drive.points.len() ); assert_kept_from(&sent_timed.points, &drive.points); + // At this speed a fix is due about every 20 s, so a longer gap means + // thinning threw away more than it should. + for pair in drive.points.windows(2) { + let gap = pair[1].time.unwrap() - pair[0].time.unwrap(); + assert!(gap.whole_seconds() <= 25, "thinned too hard: {gap}"); + } // Export all holds both, the same as exporting each alone. let (code, all) = http(addr, "GET", "/api/tracks/export.gpx", "").await; diff --git a/crates/carchompd/src/recorder.rs b/crates/carchompd/src/recorder.rs index e390d7e..d582ea4 100644 --- a/crates/carchompd/src/recorder.rs +++ b/crates/carchompd/src/recorder.rs @@ -409,7 +409,7 @@ mod tests { ( Some(60.0), true, - "inside road_heading: current tolerance, not a ruling", + "crossing inside road_heading counts as known", ), (None, true, "no course: nearness alone decides"), ] { @@ -620,4 +620,64 @@ mod tests { rec.fix(source, &stopped).await.unwrap(); assert_eq!(*rec.status.borrow(), Status::default(), "idle ended it"); } + + /// Needs PostGIS: see `known_road_query`. Its own database, as it commits. + /// The receiver is unplugged mid-drive: no fix ever arrives to say the car + /// stopped, so only the local deadline can end the track. + #[tokio::test] + #[ignore = "needs PostGIS at DATABASE_URL"] + async fn silence_ends_the_track_at_the_deadline() { + let db = crate::test_db::create("idle_deadline").await; + let source = source_id(&db, "gps", "test").await.unwrap(); + let (bus, _) = tokio::sync::broadcast::channel(16); + let (status, mut seen) = watch::channel(Status::default()); + let config = Config { + track_idle: 0.5, + ..Config::default() + }; + tokio::spawn(run(db.clone(), bus.clone(), status, &config)); + + // The recorder subscribes once it first runs, so keep sending until it + // has heard: fixes sent before then are lost. Then fall silent. + let start = OffsetDateTime::now_utc(); + let fix = |ms: i64, lon: f64| Event { + source, + obs: Observation::Fix(Fix { + time: start + Duration::milliseconds(ms), + lat: 45.5, + lon, + alt: None, + speed: Some(20.0), + course: Some(90.0), + h_err: None, + }), + }; + let mut sent = 0; + let track = loop { + let _ = bus.send(fix(10 * sent, -122.7)); + sent += 1; + if let Some(track) = seen.borrow().track { + break track; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + + // Far enough along that the track is long enough to keep. + bus.send(fix(10 * sent, -122.69)).unwrap(); + + tokio::time::timeout( + std::time::Duration::from_secs(10), + seen.wait_for(|s| s.track.is_none()), + ) + .await + .expect("silence should end the track") + .unwrap(); + let ended: Option = + sqlx::query_scalar("SELECT ended FROM track WHERE id = $1") + .bind(track) + .fetch_one(&db) + .await + .unwrap(); + assert!(ended.is_some(), "the track was finished in the database"); + } } From 043d2ab7bfbd1827bf147ce1bb0be0234ef0f2aa Mon Sep 17 00:00:00 2001 From: Joshua Davis Date: Mon, 2 Nov 2026 15:36:45 -0500 Subject: [PATCH 2/2] Make the deadline test survive a slow start A stall could let the deadline beat queued fixes and drop the track the test was following. The car now never moves, so every track is dropped, and the test waits for all of them to go. Story: CHOMP-3 Story: CHOMP-5 Co-Authored-By: Claude Opus 5.5 --- crates/carchompd/src/recorder.rs | 73 +++++++++++++++----------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/crates/carchompd/src/recorder.rs b/crates/carchompd/src/recorder.rs index d582ea4..1f21ca1 100644 --- a/crates/carchompd/src/recorder.rs +++ b/crates/carchompd/src/recorder.rs @@ -622,15 +622,18 @@ mod tests { } /// Needs PostGIS: see `known_road_query`. Its own database, as it commits. - /// The receiver is unplugged mid-drive: no fix ever arrives to say the car - /// stopped, so only the local deadline can end the track. + /// The receiver is unplugged: no fix ever arrives to say the car stopped, + /// so only the local deadline can end the track. The car never moves, so + /// each track is too short to keep and finishing removes it. A slow start + /// can let the deadline beat queued fixes, which start another track, so + /// this waits for all of them to go rather than following one. #[tokio::test] #[ignore = "needs PostGIS at DATABASE_URL"] async fn silence_ends_the_track_at_the_deadline() { let db = crate::test_db::create("idle_deadline").await; let source = source_id(&db, "gps", "test").await.unwrap(); let (bus, _) = tokio::sync::broadcast::channel(16); - let (status, mut seen) = watch::channel(Status::default()); + let (status, seen) = watch::channel(Status::default()); let config = Config { track_idle: 0.5, ..Config::default() @@ -640,44 +643,38 @@ mod tests { // The recorder subscribes once it first runs, so keep sending until it // has heard: fixes sent before then are lost. Then fall silent. let start = OffsetDateTime::now_utc(); - let fix = |ms: i64, lon: f64| Event { - source, - obs: Observation::Fix(Fix { - time: start + Duration::milliseconds(ms), - lat: 45.5, - lon, - alt: None, - speed: Some(20.0), - course: Some(90.0), - h_err: None, - }), - }; let mut sent = 0; - let track = loop { - let _ = bus.send(fix(10 * sent, -122.7)); + while seen.borrow().track.is_none() { + let _ = bus.send(Event { + source, + obs: Observation::Fix(Fix { + time: start + Duration::milliseconds(10 * sent), + lat: 45.5, + lon: -122.7, + alt: None, + speed: Some(20.0), + course: Some(90.0), + h_err: None, + }), + }); sent += 1; - if let Some(track) = seen.borrow().track { - break track; - } tokio::time::sleep(std::time::Duration::from_millis(10)).await; - }; - - // Far enough along that the track is long enough to keep. - bus.send(fix(10 * sent, -122.69)).unwrap(); + } - tokio::time::timeout( - std::time::Duration::from_secs(10), - seen.wait_for(|s| s.track.is_none()), - ) - .await - .expect("silence should end the track") - .unwrap(); - let ended: Option = - sqlx::query_scalar("SELECT ended FROM track WHERE id = $1") - .bind(track) - .fetch_one(&db) - .await - .unwrap(); - assert!(ended.is_some(), "the track was finished in the database"); + let gone = async { + loop { + let tracks: i64 = sqlx::query_scalar("SELECT count(*) FROM track") + .fetch_one(&db) + .await + .unwrap(); + if tracks == 0 && seen.borrow().track.is_none() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(10), gone) + .await + .expect("silence should end the track"); } }