From 64914f9aa03e1349297d8bc6b87454422fa45d32 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 3 Oct 2026 16:16:47 +0200 Subject: [PATCH] progress --- gpx-rs/engine/src/core/algorithm/simplify.rs | 68 ++++++++- gpx-rs/engine/src/core/algorithm/smooth.rs | 87 +++++++++++ gpx-rs/engine/src/core/gpx/chunk.rs | 38 +++++ gpx-rs/engine/src/core/gpx/common.rs | 49 ++++++ gpx-rs/engine/src/core/gpx/segment.rs | 61 ++++++-- .../engine/src/core/statistics/statistics.rs | 123 +++++++++++++-- gpx-rs/engine/src/core/statistics/utils.rs | 41 ++++- gpx-rs/engine/src/core/utils.rs | 82 ++++++++++ gpx-rs/engine/src/engine/command/applier.rs | 88 +++++++++++ gpx-rs/engine/src/engine/command/pattern.rs | 141 ++++++++++++++++++ .../src/engine/derived/statistics_buffer.rs | 92 +++++++++++- .../src/engine/derived/statistics_cache.rs | 118 +++++++++++++++ gpx-rs/engine/src/engine/engine.rs | 70 +++++++++ gpx-rs/engine/src/engine/state/stack.rs | 101 ++++++++++++- 14 files changed, 1127 insertions(+), 32 deletions(-) diff --git a/gpx-rs/engine/src/core/algorithm/simplify.rs b/gpx-rs/engine/src/core/algorithm/simplify.rs index e55f74638..9a2fe0723 100644 --- a/gpx-rs/engine/src/core/algorithm/simplify.rs +++ b/gpx-rs/engine/src/core/algorithm/simplify.rs @@ -14,7 +14,7 @@ where match trkseg.len() { 0 => vec![], 1 => vec![trkseg.first_index().unwrap()], - 2 => vec![trkseg.last_index().unwrap()], + 2 => vec![trkseg.first_index().unwrap(), trkseg.last_index().unwrap()], _ => { let first = trkseg.first_index().unwrap(); let last = trkseg.last_index().unwrap(); @@ -67,3 +67,69 @@ fn ramer_douglas_peucker_helper( } } } + +#[cfg(test)] +mod tests { + use crate::{Trackpoint, TrackpointChunk}; + + use super::*; + + fn segment(eles: &[f64]) -> TrackSegment { + let mut trkseg = TrackSegment::default(); + // several small chunks, to also exercise the chunk boundaries + for part in eles.chunks(3) { + let mut chunk = TrackpointChunk::default(); + for ele in part { + chunk.trkpt.push(Trackpoint { + ele: *ele, + ..Default::default() + }); + } + trkseg.push(chunk); + } + trkseg + } + + fn simplify(eles: &[f64], epsilon: f64) -> Vec { + let trkseg = segment(eles); + ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon) + .iter() + .map(|idx| idx.flat) + .collect() + } + + #[test] + fn test_empty_and_single_point() { + assert!(simplify(&[], 1.0).is_empty()); + assert_eq!(simplify(&[5.0], 1.0), vec![0]); + } + + #[test] + fn test_collinear_points_are_removed() { + let eles: Vec = (0..20).map(|i| i as f64 * 2.0).collect(); + assert_eq!(simplify(&eles, 0.1), vec![0, 19]); + } + + #[test] + fn test_peak_is_kept_when_above_epsilon() { + let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0]; + assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]); + } + + #[test] + fn test_peak_is_dropped_when_below_epsilon() { + let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0]; + assert_eq!(simplify(&eles, 1.0), vec![0, 6]); + } + + #[test] + fn test_indices_are_sorted_and_keep_extremities() { + let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0]; + let kept = simplify(&eles, 0.5); + assert_eq!(kept.first(), Some(&0)); + assert_eq!(kept.last(), Some(&9)); + assert!(kept.windows(2).all(|w| w[0] < w[1])); + // a larger epsilon never keeps more points + assert!(simplify(&eles, 5.0).len() <= kept.len()); + } +} diff --git a/gpx-rs/engine/src/core/algorithm/smooth.rs b/gpx-rs/engine/src/core/algorithm/smooth.rs index 92ca96bb8..9d1ffbccf 100644 --- a/gpx-rs/engine/src/core/algorithm/smooth.rs +++ b/gpx-rs/engine/src/core/algorithm/smooth.rs @@ -52,3 +52,90 @@ macro_rules! for_each_window { } }}; } + +#[cfg(test)] +mod tests { + use crate::{TrackSegment, Trackpoint, TrackpointChunk, for_each_window}; + + fn segment(eles: &[f64]) -> TrackSegment { + let mut trkseg = TrackSegment::default(); + // several small chunks, to also exercise the chunk boundaries + for part in eles.chunks(3) { + let mut chunk = TrackpointChunk::default(); + for ele in part { + chunk.trkpt.push(Trackpoint { + ele: *ele, + ..Default::default() + }); + } + trkseg.push(chunk); + } + trkseg + } + + /// Returns (center, left, right) flat indices for each visited center. + fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> { + let trkseg = segment(&vec![0.0; len]); + let mut visited = vec![]; + for_each_window!( + trkseg, + trkseg.first_index(), + trkseg.last_index(), + window, + |a, b| (b.flat - a.flat) as f64, + |i, l, r| { + visited.push((i.flat, l.flat, r.flat)); + }, + ); + visited + } + + #[test] + fn test_window_bounds() { + let visited = windows(10, 2.0); + assert_eq!(visited.len(), 10); + for (i, l, r) in visited { + assert_eq!(l, i.saturating_sub(2), "left of {i}"); + assert_eq!(r, (i + 2).min(9), "right of {i}"); + } + } + + #[test] + fn test_window_always_includes_neighbours() { + for (i, l, r) in windows(6, 0.0) { + assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5))); + } + } + + #[test] + fn test_window_larger_than_segment() { + for (_, l, r) in windows(5, 100.0) { + assert_eq!((l, r), (0, 4)); + } + } + + #[test] + fn test_window_sub_range() { + let trkseg = segment(&vec![0.0; 10]); + let mut centers = vec![]; + let mut first = trkseg.first_index(); + for _ in 0..3 { + first = trkseg.next_index(first); + } + let mut last = first; + for _ in 0..2 { + last = trkseg.next_index(last); + } + for_each_window!( + trkseg, + first, + last, + 1.0, + |a, b| (b.flat - a.flat) as f64, + |i, _l, _r| { + centers.push(i.flat); + }, + ); + assert_eq!(centers, vec![3, 4, 5]); + } +} diff --git a/gpx-rs/engine/src/core/gpx/chunk.rs b/gpx-rs/engine/src/core/gpx/chunk.rs index 52952f5f8..9deff2d0c 100644 --- a/gpx-rs/engine/src/core/gpx/chunk.rs +++ b/gpx-rs/engine/src/core/gpx/chunk.rs @@ -59,3 +59,41 @@ impl WaypointChunk { self.wpt.len() == MAX_WPT_CHUNK_SIZE } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trackpoint_chunk_full() { + let mut chunk = TrackpointChunk::default(); + assert!(!chunk.is_full()); + for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 { + chunk.trkpt.push(Trackpoint::default()); + } + assert!(!chunk.is_full()); + chunk.trkpt.push(Trackpoint::default()); + assert!(chunk.is_full()); + } + + #[test] + fn test_waypoint_chunk_full() { + let mut chunk = WaypointChunk::default(); + assert!(!chunk.is_full()); + for _ in 0..MAX_WPT_CHUNK_SIZE { + chunk.wpt.push(Waypoint::default()); + } + assert!(chunk.is_full()); + } + + #[test] + fn test_chunk_equality_is_by_id() { + let a = TrackpointChunk::default(); + let b = TrackpointChunk::default(); + assert_ne!(a, b); + let mut c = TrackpointChunk::default(); + c.id = TrackpointChunkId(a.id.0); + c.trkpt.push(Trackpoint::default()); + assert_eq!(a, c); + } +} diff --git a/gpx-rs/engine/src/core/gpx/common.rs b/gpx-rs/engine/src/core/gpx/common.rs index 1fd1f3501..fe1965bf3 100644 --- a/gpx-rs/engine/src/core/gpx/common.rs +++ b/gpx-rs/engine/src/core/gpx/common.rs @@ -46,3 +46,52 @@ impl LngLatBounds { self.ne.lat = self.ne.lat.max(other.ne.lat); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bounds_extend() { + let mut bounds = LngLatBounds::default(); + bounds.extend(LngLat { + lng: 4.0, + lat: 50.0, + }); + assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0)); + assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0)); + + bounds.extend(LngLat { + lng: 6.0, + lat: 45.0, + }); + bounds.extend(LngLat { + lng: 5.0, + lat: 48.0, + }); + assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0)); + assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0)); + } + + #[test] + fn test_bounds_merge() { + let mut a = LngLatBounds::default(); + a.extend(LngLat { lng: 0.0, lat: 0.0 }); + a.extend(LngLat { lng: 1.0, lat: 1.0 }); + let mut b = LngLatBounds::default(); + b.extend(LngLat { + lng: -2.0, + lat: 0.5, + }); + b.extend(LngLat { lng: 0.5, lat: 3.0 }); + + a.merge(&b); + assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0)); + assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0)); + + // merging an empty bounds changes nothing + a.merge(&LngLatBounds::default()); + assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0)); + assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0)); + } +} diff --git a/gpx-rs/engine/src/core/gpx/segment.rs b/gpx-rs/engine/src/core/gpx/segment.rs index f32468a94..f06aeb30f 100644 --- a/gpx-rs/engine/src/core/gpx/segment.rs +++ b/gpx-rs/engine/src/core/gpx/segment.rs @@ -81,19 +81,20 @@ impl TrackSegment { pos: 0, flat: self.cumul_length.last().copied().unwrap_or_default(), }); - loop { - if prev.pos == 0 { - if prev.chunk == 0 { - return None; - } + if prev.pos == 0 { + while prev.chunk > 0 { prev.chunk -= 1; - prev.pos = self.chunks[prev.chunk].trkpt.len(); - prev.flat -= 1; - } else { - prev.pos -= 1; - prev.flat -= 1; - return Some(prev); + if !self.chunks[prev.chunk].trkpt.is_empty() { + prev.pos = self.chunks[prev.chunk].trkpt.len() - 1; + prev.flat -= 1; + return Some(prev); + } } + None + } else { + prev.pos -= 1; + prev.flat -= 1; + Some(prev) } } @@ -268,4 +269,42 @@ mod tests { assert!(std::ptr::eq(&trkseg[i], trkpt)); } } + + #[test] + fn test_first_last_index() { + assert!(TrackSegment::default().first_index().is_none()); + assert!(TrackSegment::default().last_index().is_none()); + + let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5 + let first = trkseg.first_index().unwrap(); + let last = trkseg.last_index().unwrap(); + assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0)); + assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14)); + assert_eq!(trkseg[last].ele, 14.0); + assert_eq!(trkseg.locate(14), Some(last)); + assert!(trkseg.next_index(Some(last)).is_none()); + assert!(trkseg.prev_index(Some(first)).is_none()); + } + + #[test] + fn test_prev_next_are_inverse() { + let trkseg = create_track_segment(6); + let mut idx = trkseg.last_index(); + let mut visited = vec![]; + while let Some(i) = idx { + assert_eq!(trkseg.locate(i.flat), Some(i)); + visited.push(i.flat); + idx = trkseg.prev_index(Some(i)); + } + assert_eq!(visited, (0..trkseg.len()).rev().collect::>()); + + let mut idx = trkseg.first_index(); + while let Some(i) = idx { + let next = trkseg.next_index(Some(i)); + if let Some(n) = next { + assert_eq!(trkseg.prev_index(Some(n)), Some(i)); + } + idx = next; + } + } } diff --git a/gpx-rs/engine/src/core/statistics/statistics.rs b/gpx-rs/engine/src/core/statistics/statistics.rs index 2f734a2fd..4a9c470f4 100644 --- a/gpx-rs/engine/src/core/statistics/statistics.rs +++ b/gpx-rs/engine/src/core/statistics/statistics.rs @@ -1,6 +1,6 @@ use crate::{ LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window, - ramer_douglas_peucker, slope, speed, sum_options, time_diff, + max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff, }; #[derive(Default, Debug)] @@ -10,10 +10,6 @@ pub struct Statistics { } impl Statistics { - pub fn total_time(&self) -> Option { - self.global.total_time() - } - pub fn total_speed(&self) -> Option { self.global.total_speed() } @@ -76,6 +72,7 @@ impl Statistics { self.global.start_time = Some(time); } self.global.end_time = Some(time); + self.global.total_time = time_diff(&self.global.end_time, &self.global.start_time); } } @@ -214,6 +211,7 @@ impl Statistics { pub struct GlobalStatistics { pub total_distance: f64, pub moving_distance: Option, + pub total_time: Option, pub moving_time: Option, pub elevation_gain: f64, pub elevation_loss: f64, @@ -223,12 +221,8 @@ pub struct GlobalStatistics { } impl GlobalStatistics { - pub fn total_time(&self) -> Option { - time_diff(&self.start_time, &self.end_time) - } - pub fn total_speed(&self) -> Option { - self.total_time().map(|t| speed(self.total_distance, t)) + self.total_time.map(|t| speed(self.total_distance, t)) } pub fn moving_speed(&self) -> Option { @@ -240,9 +234,12 @@ impl GlobalStatistics { pub fn merge(&mut self, other: &GlobalStatistics) { self.total_distance += other.total_distance; self.moving_distance = sum_options(self.moving_distance, other.moving_distance); + self.total_time = sum_options(self.total_time, other.total_time); self.moving_time = sum_options(self.moving_time, other.moving_time); self.elevation_gain += other.elevation_gain; self.elevation_loss += other.elevation_loss; + self.start_time = min_options(self.start_time, other.start_time); + self.end_time = max_options(self.end_time, other.end_time); self.bounds.merge(&other.bounds); } } @@ -265,7 +262,7 @@ impl TrackpointStatistics { Self { total_distance: stats.global.total_distance, moving_distance: stats.global.moving_distance, - total_time: stats.total_time(), + total_time: stats.global.total_time, moving_time: stats.global.moving_time, // stats below are computed later speed: None, @@ -309,5 +306,107 @@ mod tests { } } - // TODO more tests + fn load(path: &str) -> crate::File { + let mut f = File::open(path).unwrap(); + let mut data = String::new(); + let _ = f.read_to_string(&mut data); + parse(data.as_bytes()).unwrap() + } + + #[test] + fn test_compute_empty_segment() { + let stats = Statistics::compute(&TrackSegment::default()); + assert!(stats.local.is_empty()); + assert_eq!(stats.global.total_distance, 0.0); + } + + #[test] + fn test_compute_distance_and_bounds() { + let gpx = load("data/simple.gpx"); + let trkseg = &gpx.trk[0].trkseg[0]; + let stats = Statistics::compute(trkseg); + + assert_eq!(stats.local.len(), trkseg.len()); + assert_eq!(stats.local[0].total_distance, 0.0); + assert!(stats.global.total_distance > 0.0); + // cumulative distance never decreases and ends at the global distance + assert!( + stats + .local + .windows(2) + .all(|w| w[0].total_distance <= w[1].total_distance) + ); + let last = stats.local.last().unwrap(); + assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9); + assert!(stats.global.elevation_gain >= 0.0); + assert!(stats.global.elevation_loss >= 0.0); + + // every point lies inside the bounds + let b = &stats.global.bounds; + for trkpt in trkseg.iter() { + assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng); + assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat); + } + } + + #[test] + fn test_compute_time() { + let gpx = load("data/with_time.gpx"); + let stats = Statistics::compute(&gpx.trk[0].trkseg[0]); + let total_time = stats.global.total_time.unwrap(); + assert!(total_time > 0); + let speed = stats.global.total_speed().unwrap(); + assert!((speed - 20.0).abs() < 0.5, "{speed}"); + } + + #[test] + fn test_no_time_without_timestamps() { + let gpx = load("data/simple.gpx"); + let stats = Statistics::compute(&gpx.trk[0].trkseg[0]); + assert!(stats.global.total_time.is_none()); + assert!(stats.global.total_speed().is_none()); + assert!(stats.local.iter().all(|s| s.speed.is_none())); + } + + #[test] + fn test_global_merge() { + let mut a = GlobalStatistics::default(); + a.total_distance = 1.0; + a.elevation_gain = 10.0; + a.moving_time = Some(5); + a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 }); + let mut b = GlobalStatistics::default(); + b.total_distance = 2.0; + b.elevation_loss = 4.0; + b.moving_distance = Some(1.5); + b.moving_time = Some(7); + b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 }); + + a.merge(&b); + assert_eq!(a.total_distance, 3.0); + assert_eq!(a.elevation_gain, 10.0); + assert_eq!(a.elevation_loss, 4.0); + assert_eq!(a.moving_distance, Some(1.5)); + assert_eq!(a.moving_time, Some(12)); + assert_eq!(a.total_time, None); + + let mut c = GlobalStatistics::default(); + c.start_time = Some(1_000); + c.end_time = Some(4_000); + c.total_time = Some(3_000); + let mut d = GlobalStatistics::default(); + d.start_time = Some(10_000); + d.end_time = Some(12_000); + d.total_time = Some(2_000); + c.merge(&d); + // 3 s + 2 s, the gap between the two is ignored + assert_eq!(c.total_time, Some(5_000)); + let mut cumul = GlobalStatistics::default(); + cumul.merge(&c); + cumul.merge(&d); + assert_eq!(cumul.start_time, Some(1_000)); + assert_eq!(cumul.end_time, Some(12_000)); + assert_eq!(cumul.total_time, Some(7_000)); + assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0)); + } } diff --git a/gpx-rs/engine/src/core/statistics/utils.rs b/gpx-rs/engine/src/core/statistics/utils.rs index 48b710165..627a151a9 100644 --- a/gpx-rs/engine/src/core/statistics/utils.rs +++ b/gpx-rs/engine/src/core/statistics/utils.rs @@ -1,13 +1,44 @@ use std::ops::Add; +pub fn combine_options(a: Option, b: Option, f: impl FnOnce(T, T) -> T) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(f(a, b)), + (Some(v), None) | (None, Some(v)) => Some(v), + (None, None) => None, + } +} + pub fn sum_options(a: Option, b: Option) -> Option where T: Add, { - match (a, b) { - (Some(a), Some(b)) => Some(a + b), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, + combine_options(a, b, |a, b| a + b) +} + +pub fn min_options(a: Option, b: Option) -> Option +where + T: Ord, +{ + combine_options(a, b, |a, b| a.min(b)) +} + +pub fn max_options(a: Option, b: Option) -> Option +where + T: Ord, +{ + combine_options(a, b, |a, b| a.max(b)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sum_options() { + assert_eq!(sum_options(Some(1), Some(2)), Some(3)); + assert_eq!(sum_options(Some(1), None), Some(1)); + assert_eq!(sum_options(None, Some(2)), Some(2)); + assert_eq!(sum_options::(None, None), None); + assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75)); } } diff --git a/gpx-rs/engine/src/core/utils.rs b/gpx-rs/engine/src/core/utils.rs index 8673d827f..4850d59ad 100644 --- a/gpx-rs/engine/src/core/utils.rs +++ b/gpx-rs/engine/src/core/utils.rs @@ -119,3 +119,85 @@ pub fn crossarc(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> f64 { ((x3 - proj_x) * (x3 - proj_x) + (y3 - proj_y) * (y3 - proj_y)).sqrt() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn p(lng: f64, lat: f64) -> LngLat { + LngLat { lng, lat } + } + + #[test] + fn test_distance() { + assert_eq!(distance(p(4.0, 50.0), p(4.0, 50.0)), 0.0); + // one degree of latitude + let d = distance(p(0.0, 0.0), p(0.0, 1.0)); + assert!((d - 111.195).abs() < 0.01, "{d}"); + // symmetric + let a = p(4.40, 50.79); + let b = p(6.13, 45.90); + assert!((distance(a, b) - distance(b, a)).abs() < 1e-9); + // half the Earth's circumference, without NaN from rounding + let antipodal = distance(p(0.0, 0.0), p(180.0, 0.0)); + assert!((antipodal - PI * EARTH_RADIUS).abs() < 1e-6); + } + + #[test] + fn test_time_diff() { + assert_eq!(time_diff(&Some(5000), &Some(2000)), Some(3000)); + assert_eq!(time_diff(&Some(2000), &Some(5000)), Some(-3000)); + assert_eq!(time_diff(&None, &Some(1)), None); + assert_eq!(time_diff(&Some(1), &None), None); + } + + #[test] + fn test_speed() { + // 1 km in 1 h + assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12); + // 10 km in 30 min + assert!((speed(10.0, 1_800_000) - 20.0).abs() < 1e-12); + } + + #[test] + fn test_slope() { + // 10 m of elevation over 100 m (distance is expressed in km, hence the 0.1 factor) + assert!((slope(10.0, 0.1) - 10.0).abs() < 1e-12); + assert!((slope(-5.0, 0.1) + 5.0).abs() < 1e-12); + assert_eq!(slope(0.0, 1.0), 0.0); + assert_eq!(slope(10.0, 0.0), 100.0); + } + + #[test] + fn test_crossarc() { + // perpendicular distance to the segment (0,0)-(10,0) + assert!((crossarc(0.0, 0.0, 10.0, 0.0, 5.0, 3.0) - 3.0).abs() < 1e-12); + // beyond the ends: distance to the closest endpoint + assert!((crossarc(0.0, 0.0, 10.0, 0.0, 13.0, 4.0) - 5.0).abs() < 1e-12); + assert!((crossarc(0.0, 0.0, 10.0, 0.0, -3.0, 4.0) - 5.0).abs() < 1e-12); + // point on the segment + assert_eq!(crossarc(0.0, 0.0, 10.0, 0.0, 4.0, 0.0), 0.0); + // degenerate segment + assert!((crossarc(1.0, 1.0, 1.0, 1.0, 4.0, 5.0) - 5.0).abs() < 1e-12); + } + + #[test] + fn test_projected() { + let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0)); + assert!((proj.lng - 0.5).abs() < 1e-9); + assert!(proj.lat.abs() < 1e-9); + // clamped to the segment + let proj = projected(p(0.0, 0.0), p(1.0, 0.0), p(2.0, 1.0)); + assert!((proj.lng - 1.0).abs() < 1e-9); + // degenerate segment + let proj = projected(p(3.0, 4.0), p(3.0, 4.0), p(5.0, 6.0)); + assert_eq!((proj.lng, proj.lat), (3.0, 4.0)); + } + + #[test] + fn test_crossarc_lnglat() { + // about 1 degree of latitude away from an east-west segment on the equator + let d = crossarc_lnglat(p(0.0, 0.0), p(1.0, 0.0), p(0.5, 1.0)); + assert!((d - METERS_PER_LATITUDE_DEGREE).abs() < 1e-6); + } +} diff --git a/gpx-rs/engine/src/engine/command/applier.rs b/gpx-rs/engine/src/engine/command/applier.rs index bf3c4c214..fbdfdb722 100644 --- a/gpx-rs/engine/src/engine/command/applier.rs +++ b/gpx-rs/engine/src/engine/command/applier.rs @@ -24,3 +24,91 @@ pub fn update_metadata(state: &mut State, name: &str, desc: &str) -> Result<(), }); Ok(()) } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{FileId, FileOrder, Selection, StackEntry}; + + use super::*; + + #[derive(Default)] + struct Fixture { + files: StackEntry, + selection: Selection, + order: FileOrder, + } + + impl Fixture { + fn state(&mut self) -> State<'_> { + State { + files: &mut self.files, + selection: &mut self.selection, + order: &mut self.order, + } + } + + fn selected_ids(&self) -> HashSet { + match &self.selection { + Selection::File { file_ids } => file_ids.clone(), + _ => HashSet::new(), + } + } + } + + #[test] + fn test_create_file() { + let mut fx = Fixture::default(); + assert!(create_file(&mut fx.state(), "new").is_ok()); + assert_eq!(fx.files.len(), 1); + let file = fx.files.values().next().unwrap(); + assert_eq!(file.info.name, "new"); + assert!(file.trk.is_empty()); + assert_eq!(fx.selected_ids(), HashSet::from([file.id])); + assert_eq!(fx.order.0, vec![file.id]); + } + + #[test] + fn test_load_file() { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks.gpx").unwrap(); + assert!(load_file(&mut fx.state(), &data).is_ok()); + assert_eq!(fx.files.len(), 1); + let file = fx.files.values().next().unwrap(); + assert!(!file.trk.is_empty()); + assert_eq!(fx.selected_ids(), HashSet::from([file.id])); + } + + #[test] + fn test_load_invalid_file_changes_nothing() { + let mut fx = Fixture::default(); + assert!(create_file(&mut fx.state(), "keep").is_ok()); + let selected = fx.selected_ids(); + + assert!(load_file(&mut fx.state(), b"").is_err()); + assert_eq!(fx.files.len(), 1); + assert_eq!(fx.order.0.len(), 1); + assert_eq!(fx.selected_ids(), selected); + } + + #[test] + fn test_update_metadata_applies_to_selected_file() { + let mut fx = Fixture::default(); + create_file(&mut fx.state(), "first").unwrap(); + create_file(&mut fx.state(), "second").unwrap(); // now selected + let selected = *fx.selected_ids().iter().next().unwrap(); + + update_metadata(&mut fx.state(), "renamed", "description").unwrap(); + + for (id, file) in fx.files.iter() { + if *id == selected { + assert_eq!(file.info.name, "renamed"); + assert_eq!(file.info.desc.as_deref(), Some("description")); + } else { + assert_eq!(file.info.name, "first"); + assert_eq!(file.info.desc, None); + } + } + } +} diff --git a/gpx-rs/engine/src/engine/command/pattern.rs b/gpx-rs/engine/src/engine/command/pattern.rs index f01854fe9..527ed73cc 100644 --- a/gpx-rs/engine/src/engine/command/pattern.rs +++ b/gpx-rs/engine/src/engine/command/pattern.rs @@ -36,3 +36,144 @@ where } } } + +#[cfg(test)] +mod tests { + use crate::FileOrder; + + use super::*; + + fn named(name: &str) -> File { + let mut file = File::default(); + file.info.name = name.to_string(); + file + } + + struct Fixture { + files: crate::StackEntry, + selection: Selection, + order: FileOrder, + } + + impl Fixture { + fn new() -> Self { + Self { + files: Default::default(), + selection: Selection::Empty, + order: FileOrder::default(), + } + } + + fn state(&mut self) -> State<'_> { + State { + files: &mut self.files, + selection: &mut self.selection, + order: &mut self.order, + } + } + + fn selected(&self) -> HashSet { + match &self.selection { + Selection::File { file_ids } => file_ids.clone(), + _ => HashSet::new(), + } + } + } + + #[test] + fn test_produce_adds_orders_and_selects_first() { + let mut fx = Fixture::new(); + let (a, b) = (named("a"), named("b")); + let (id_a, id_b) = (a.id, b.id); + produce(&mut fx.state(), |_| vec![a, b]); + + assert_eq!(fx.files.len(), 2); + assert_eq!(fx.order.0, vec![id_a, id_b]); + assert_eq!(fx.selected(), HashSet::from([id_a])); + } + + #[test] + fn test_produce_replaces_selection_and_keeps_order() { + let mut fx = Fixture::new(); + let a = named("a"); + let id_a = a.id; + produce(&mut fx.state(), |_| vec![a]); + let b = named("b"); + let id_b = b.id; + produce(&mut fx.state(), |_| vec![b]); + + assert_eq!(fx.order.0, vec![id_a, id_b]); + assert_eq!(fx.selected(), HashSet::from([id_b])); + } + + #[test] + fn test_produce_nothing_clears_selection() { + let mut fx = Fixture::new(); + let a = named("a"); + produce(&mut fx.state(), |_| vec![a]); + produce(&mut fx.state(), |_| vec![]); + assert!(matches!(fx.selection, Selection::Empty)); + assert_eq!(fx.files.len(), 1); + } + + #[test] + fn test_produce_sees_current_state() { + let mut fx = Fixture::new(); + let a = named("a"); + produce(&mut fx.state(), |_| vec![a]); + produce(&mut fx.state(), |state| { + assert_eq!(state.files.len(), 1); + vec![] + }); + } + + #[test] + fn test_update_each_selected_file_only_touches_selection() { + let mut fx = Fixture::new(); + let (a, b, c) = (named("a"), named("b"), named("c")); + let ids = [a.id, b.id, c.id]; + for file in [a, b, c] { + fx.files.insert(file.id, Rc::new(file)); + } + let untouched = fx.files[&ids[2]].clone(); + fx.selection = Selection::File { + file_ids: HashSet::from([ids[0], ids[1]]), + }; + + let mut calls = 0; + update_each_selected_file(&mut fx.state(), &mut |file| { + calls += 1; + let mut next = file.clone(); + next.info.name.push('!'); + next + }); + + assert_eq!(calls, 2); + assert_eq!(fx.files[&ids[0]].info.name, "a!"); + assert_eq!(fx.files[&ids[1]].info.name, "b!"); + assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched)); + } + + #[test] + fn test_update_each_selected_file_ignores_missing_and_other_selections() { + let mut fx = Fixture::new(); + let a = named("a"); + let id = a.id; + fx.files.insert(id, Rc::new(a)); + + let mut calls = 0; + let mut count = |file: &File| { + calls += 1; + file.clone() + }; + fx.selection = Selection::File { + file_ids: HashSet::from([FileId::default()]), + }; + update_each_selected_file(&mut fx.state(), &mut count); + fx.selection = Selection::Waypoints { file_id: id }; + update_each_selected_file(&mut fx.state(), &mut count); + fx.selection = Selection::Empty; + update_each_selected_file(&mut fx.state(), &mut count); + assert_eq!(calls, 0); + } +} diff --git a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs index 59536d33a..c4ec0b9a8 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_buffer.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_buffer.rs @@ -37,8 +37,7 @@ impl StatisticsBuffer { .unwrap_or_default(), ); self.total_time.push( - sum_options(cumul_stats.total_time(), trkpt_stats.total_time) - .unwrap_or_default(), + sum_options(cumul_stats.total_time, trkpt_stats.total_time).unwrap_or_default(), ); self.moving_time.push( sum_options(cumul_stats.moving_time, trkpt_stats.moving_time) @@ -59,3 +58,92 @@ impl StatisticsBuffer { } } } + +#[cfg(test)] +mod tests { + use crate::parse; + + use super::*; + + fn stats(path: &str) -> Statistics { + let data = std::fs::read(path).unwrap(); + let file = parse(&data).unwrap(); + Statistics::compute(&file.trk[0].trkseg[0]) + } + + #[test] + fn test_empty() { + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[]); + assert!(buffer.total_distance.is_empty()); + assert!(buffer.speed.is_empty()); + } + + #[test] + fn test_single_segment_matches_local_stats() { + let s = stats("data/simple.gpx"); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[&s]); + + let n = s.local.len(); + for len in [ + buffer.total_distance.len(), + buffer.moving_distance.len(), + buffer.total_time.len(), + buffer.moving_time.len(), + buffer.speed.len(), + buffer.elevation_gain.len(), + buffer.elevation_loss.len(), + buffer.slope.len(), + buffer.slope_segment_slope.len(), + buffer.slope_segment_distance.len(), + ] { + assert_eq!(len, n); + } + for (i, local) in s.local.iter().enumerate() { + assert_eq!(buffer.total_distance[i], local.total_distance); + assert_eq!(buffer.slope[i], local.slope); + } + } + + #[test] + fn test_distances_accumulate_over_segments() { + let s = stats("data/simple.gpx"); + let n = s.local.len(); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[&s, &s]); + + assert_eq!(buffer.total_distance.len(), 2 * n); + // the second segment starts where the first one ended + assert_eq!(buffer.total_distance[n], s.global.total_distance); + assert!((buffer.total_distance[2 * n - 1] - 2.0 * s.global.total_distance).abs() < 1e-9); + assert!(buffer.total_distance.windows(2).all(|w| w[0] <= w[1])); + assert!((buffer.elevation_gain[2 * n - 1] - 2.0 * s.global.elevation_gain).abs() < 1e-9); + } + + #[test] + fn test_total_time_accumulates_over_segments() { + let s = stats("data/with_time.gpx"); + let n = s.local.len(); + let duration = s.global.total_time.unwrap(); + assert!(duration > 0); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[&s, &s]); + + assert_eq!(buffer.total_time[n - 1], duration); + assert_eq!(buffer.total_time[n], duration); + assert_eq!(buffer.total_time[2 * n - 1], 2 * duration); + assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1])); + } + + #[test] + fn test_update_replaces_previous_content() { + let s = stats("data/simple.gpx"); + let mut buffer = StatisticsBuffer::default(); + buffer.update(&[&s]); + buffer.update(&[&s]); + assert_eq!(buffer.total_distance.len(), s.local.len()); + buffer.update(&[]); + assert!(buffer.total_distance.is_empty()); + } +} diff --git a/gpx-rs/engine/src/engine/derived/statistics_cache.rs b/gpx-rs/engine/src/engine/derived/statistics_cache.rs index 3906a2303..829749275 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_cache.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_cache.rs @@ -113,3 +113,121 @@ impl StatisticsCache { } } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{FileId, parse}; + + use super::*; + + fn state(path: &str) -> (StackEntry, Rc) { + let data = std::fs::read(path).unwrap(); + let file = Rc::new(parse(&data).unwrap()); + let mut state = StackEntry::default(); + state.insert(file.id, file.clone()); + (state, file) + } + + fn file_selection(id: FileId) -> Selection { + Selection::File { + file_ids: HashSet::from([id]), + } + } + + #[test] + fn test_update_and_select_file() { + let (state, file) = state("data/with_tracks_and_segments.gpx"); + let nb_segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum(); + let mut cache = StatisticsCache::default(); + cache.update(Some(&state)); + assert_eq!(cache.map.len(), nb_segments); + + assert_eq!( + cache.get(Some(&state), &file_selection(file.id)).len(), + nb_segments + ); + assert!(cache.get(Some(&state), &Selection::Empty).is_empty()); + assert!(cache.get(None, &file_selection(file.id)).is_empty()); + // unknown file + assert!( + cache + .get(Some(&state), &file_selection(FileId::default())) + .is_empty() + ); + } + + #[test] + fn test_select_track_and_segment() { + let (state, file) = state("data/with_tracks_and_segments.gpx"); + let mut cache = StatisticsCache::default(); + cache.update(Some(&state)); + + let trk = &file.trk[0]; + let selection = Selection::Track { + file_id: file.id, + trk_ids: HashSet::from([trk.id]), + }; + assert_eq!(cache.get(Some(&state), &selection).len(), trk.trkseg.len()); + + let trkseg = &trk.trkseg[1]; + let selection = Selection::TrackSegment { + file_id: file.id, + trk_id: trk.id, + trkseg_ids: HashSet::from([trkseg.id]), + }; + let stats = cache.get(Some(&state), &selection); + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].local.len(), trkseg.len()); + + // a segment id under the wrong track selects nothing + let selection = Selection::TrackSegment { + file_id: file.id, + trk_id: file.trk[1].id, + trkseg_ids: HashSet::from([trkseg.id]), + }; + assert!(cache.get(Some(&state), &selection).is_empty()); + } + + #[test] + fn test_unchanged_segments_are_not_recomputed() { + let (state, file) = state("data/simple.gpx"); + let mut cache = StatisticsCache::default(); + cache.update(Some(&state)); + let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0]; + + // a new snapshot of the file that shares its segments (same revision ids) + let mut next = StackEntry::default(); + let renamed = { + let mut f = (*file).clone(); + f.info.name = "other".to_string(); + Rc::new(f) + }; + next.insert(renamed.id, renamed); + cache.update(Some(&next)); + let after: *const Statistics = cache.get(Some(&next), &file_selection(file.id))[0]; + assert!(std::ptr::eq(before, after)); + } + + #[test] + fn test_modified_segment_is_recomputed_and_stale_entries_dropped() { + let (state, file) = state("data/simple.gpx"); + let mut cache = StatisticsCache::default(); + cache.update(Some(&state)); + let old_rev = file.trk[0].trkseg[0].rev_id; + assert!(cache.map.contains_key(&old_rev)); + + let mut f = (*file).clone(); + f.trk[0].trkseg[0].rev_id = Default::default(); + let new_rev = f.trk[0].trkseg[0].rev_id; + let mut next = StackEntry::default(); + next.insert(f.id, Rc::new(f)); + cache.update(Some(&next)); + assert!(cache.map.contains_key(&new_rev)); + assert!(!cache.map.contains_key(&old_rev)); + + cache.update(None); + assert!(cache.map.is_empty()); + } +} diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index bafe405fc..c356ddaeb 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -100,4 +100,74 @@ mod tests { assert_eq!(engine.statistics_buffer.total_distance.len(), 80); } + + fn load(engine: &mut Engine, path: &str) -> bool { + let data = std::fs::read(path).unwrap(); + engine.execute(Command::Load { data: &data }) + } + + #[test] + fn test_new_file_has_no_statistics() { + let mut engine = Engine::default(); + assert!(engine.execute(Command::New { name: "empty" })); + assert_eq!(engine.stack.current().unwrap().len(), 1); + assert_eq!(engine.order.0.len(), 1); + assert!(engine.statistics().total_distance.is_empty()); + } + + #[test] + fn test_statistics_follow_selection() { + let mut engine = Engine::default(); + assert!(load(&mut engine, "data/simple.gpx")); + let n = engine.statistics().total_distance.len(); + assert!(n > 0); + + // a newly created (empty) file becomes the selection + assert!(engine.execute(Command::New { name: "empty" })); + assert!(engine.statistics().total_distance.is_empty()); + assert_eq!(engine.stack.current().unwrap().len(), 2); + + // loading a file selects it again + assert!(load(&mut engine, "data/simple.gpx")); + assert_eq!(engine.statistics().total_distance.len(), n); + } + + #[test] + fn test_invalid_load_is_rejected() { + let mut engine = Engine::default(); + assert!(!engine.execute(Command::Load { + data: b"" + })); + assert!(engine.stack.current().is_none()); + assert!(!engine.stack.can_undo()); + assert!(engine.order.0.is_empty()); + } + + #[test] + fn test_metadata_renames_selected_file() { + let mut engine = Engine::default(); + engine.execute(Command::New { name: "before" }); + assert!(engine.execute(Command::Metadata { + name: "after", + desc: "about", + })); + let state = engine.stack.current().unwrap(); + let file = state.values().next().unwrap(); + assert_eq!(file.info.name, "after"); + assert_eq!(file.info.desc.as_deref(), Some("about")); + assert!(engine.stack.can_undo()); + } + + #[test] + fn test_metadata_without_selection_does_not_modify_files() { + let mut engine = Engine::default(); + engine.execute(Command::New { name: "file" }); + engine.selection = Selection::Empty; + engine.execute(Command::Metadata { + name: "renamed", + desc: "", + }); + let state = engine.stack.current().unwrap(); + assert_eq!(state.values().next().unwrap().info.name, "file"); + } } diff --git a/gpx-rs/engine/src/engine/state/stack.rs b/gpx-rs/engine/src/engine/state/stack.rs index 0201d6443..e403e6ad7 100644 --- a/gpx-rs/engine/src/engine/state/stack.rs +++ b/gpx-rs/engine/src/engine/state/stack.rs @@ -154,5 +154,104 @@ mod tests { assert!(diff.modified.is_empty()); } - // TODO more tests + fn add_file(stack: &mut Stack) -> FileId { + let file = Rc::new(File::default()); + let id = file.id; + stack + .create_and_push_next(|e| { + e.insert(id, file); + Ok(()) + }) + .unwrap(); + id + } + + #[test] + fn test_failed_command_pushes_nothing() { + let mut stack = Stack::default(); + let diff = stack.create_and_push_next(|e| { + e.insert(FileId::default(), Rc::new(File::default())); + Err("nope".to_string()) + }); + assert!(diff.is_none()); + assert!(!stack.can_undo()); + assert!(stack.current().is_none()); + } + + #[test] + fn test_undo_redo() { + let mut stack = Stack::default(); + assert!(stack.undo().is_none()); + assert!(stack.redo().is_none()); + + let id = add_file(&mut stack); + + let diff = stack.undo().unwrap(); + assert_eq!(diff.removed, vec![id]); + assert!(diff.added.is_empty()); + assert!(stack.current().is_none()); + assert!(!stack.can_undo()); + assert!(stack.can_redo()); + assert!(stack.undo().is_none()); + + let diff = stack.redo().unwrap(); + assert_eq!(diff.added, vec![id]); + assert!(stack.current().unwrap().contains_key(&id)); + assert!(stack.can_undo()); + assert!(!stack.can_redo()); + assert!(stack.redo().is_none()); + } + + #[test] + fn test_modified_and_removed() { + let mut stack = Stack::default(); + let id = add_file(&mut stack); + + let diff = stack + .create_and_push_next(|e| { + let mut file = (**e.get(&id).unwrap()).clone(); + file.info.name = "renamed".to_string(); + e.insert(id, Rc::new(file)); + Ok(()) + }) + .unwrap(); + assert_eq!(diff.modified, vec![id]); + assert!(diff.added.is_empty() && diff.removed.is_empty()); + + // an unchanged file is not reported as modified + let diff = stack.create_and_push_next(|_| Ok(())).unwrap(); + assert!(diff.modified.is_empty()); + + let diff = stack + .create_and_push_next(|e| { + e.remove(&id); + Ok(()) + }) + .unwrap(); + assert_eq!(diff.removed, vec![id]); + assert!(stack.current().unwrap().is_empty()); + + // going back restores the renamed file + stack.undo(); + assert_eq!(stack.current().unwrap()[&id].info.name, "renamed"); + } + + #[test] + fn test_push_after_undo_drops_redo_branch() { + let mut stack = Stack::default(); + let first = add_file(&mut stack); + let second = add_file(&mut stack); + + stack.undo(); + assert!(stack.can_redo()); + assert!(!stack.current().unwrap().contains_key(&second)); + + let third = add_file(&mut stack); + assert!(!stack.can_redo()); + assert!(stack.redo().is_none()); + let current = stack.current().unwrap(); + assert!(current.contains_key(&first)); + assert!(current.contains_key(&third)); + assert!(!current.contains_key(&second)); + } }