This commit is contained in:
vcoppe
2026-10-03 16:16:47 +02:00
parent 2abf46c747
commit 64914f9aa0
14 changed files with 1127 additions and 32 deletions
+67 -1
View File
@@ -14,7 +14,7 @@ where
match trkseg.len() { match trkseg.len() {
0 => vec![], 0 => vec![],
1 => vec![trkseg.first_index().unwrap()], 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 first = trkseg.first_index().unwrap();
let last = trkseg.last_index().unwrap(); let last = trkseg.last_index().unwrap();
@@ -67,3 +67,69 @@ fn ramer_douglas_peucker_helper<F>(
} }
} }
} }
#[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<usize> {
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<f64> = (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());
}
}
@@ -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]);
}
}
+38
View File
@@ -59,3 +59,41 @@ impl WaypointChunk {
self.wpt.len() == MAX_WPT_CHUNK_SIZE 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);
}
}
+49
View File
@@ -46,3 +46,52 @@ impl LngLatBounds {
self.ne.lat = self.ne.lat.max(other.ne.lat); 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));
}
}
+47 -8
View File
@@ -81,20 +81,21 @@ impl TrackSegment {
pos: 0, pos: 0,
flat: self.cumul_length.last().copied().unwrap_or_default(), flat: self.cumul_length.last().copied().unwrap_or_default(),
}); });
loop {
if prev.pos == 0 { if prev.pos == 0 {
if prev.chunk == 0 { while prev.chunk > 0 {
return None;
}
prev.chunk -= 1; prev.chunk -= 1;
prev.pos = self.chunks[prev.chunk].trkpt.len(); if !self.chunks[prev.chunk].trkpt.is_empty() {
prev.flat -= 1; prev.pos = self.chunks[prev.chunk].trkpt.len() - 1;
} else {
prev.pos -= 1;
prev.flat -= 1; prev.flat -= 1;
return Some(prev); return Some(prev);
} }
} }
None
} else {
prev.pos -= 1;
prev.flat -= 1;
Some(prev)
}
} }
fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> { fn locate(&self, idx: usize) -> Option<TrackSegmentIndex> {
@@ -268,4 +269,42 @@ mod tests {
assert!(std::ptr::eq(&trkseg[i], trkpt)); 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::<Vec<_>>());
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;
}
}
} }
+111 -12
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window, 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)] #[derive(Default, Debug)]
@@ -10,10 +10,6 @@ pub struct Statistics {
} }
impl Statistics { impl Statistics {
pub fn total_time(&self) -> Option<i32> {
self.global.total_time()
}
pub fn total_speed(&self) -> Option<f64> { pub fn total_speed(&self) -> Option<f64> {
self.global.total_speed() self.global.total_speed()
} }
@@ -76,6 +72,7 @@ impl Statistics {
self.global.start_time = Some(time); self.global.start_time = Some(time);
} }
self.global.end_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 struct GlobalStatistics {
pub total_distance: f64, pub total_distance: f64,
pub moving_distance: Option<f64>, pub moving_distance: Option<f64>,
pub total_time: Option<i32>,
pub moving_time: Option<i32>, pub moving_time: Option<i32>,
pub elevation_gain: f64, pub elevation_gain: f64,
pub elevation_loss: f64, pub elevation_loss: f64,
@@ -223,12 +221,8 @@ pub struct GlobalStatistics {
} }
impl GlobalStatistics { impl GlobalStatistics {
pub fn total_time(&self) -> Option<i32> {
time_diff(&self.start_time, &self.end_time)
}
pub fn total_speed(&self) -> Option<f64> { pub fn total_speed(&self) -> Option<f64> {
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<f64> { pub fn moving_speed(&self) -> Option<f64> {
@@ -240,9 +234,12 @@ impl GlobalStatistics {
pub fn merge(&mut self, other: &GlobalStatistics) { pub fn merge(&mut self, other: &GlobalStatistics) {
self.total_distance += other.total_distance; self.total_distance += other.total_distance;
self.moving_distance = sum_options(self.moving_distance, other.moving_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.moving_time = sum_options(self.moving_time, other.moving_time);
self.elevation_gain += other.elevation_gain; self.elevation_gain += other.elevation_gain;
self.elevation_loss += other.elevation_loss; 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); self.bounds.merge(&other.bounds);
} }
} }
@@ -265,7 +262,7 @@ impl TrackpointStatistics {
Self { Self {
total_distance: stats.global.total_distance, total_distance: stats.global.total_distance,
moving_distance: stats.global.moving_distance, moving_distance: stats.global.moving_distance,
total_time: stats.total_time(), total_time: stats.global.total_time,
moving_time: stats.global.moving_time, moving_time: stats.global.moving_time,
// stats below are computed later // stats below are computed later
speed: None, 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));
}
} }
+36 -5
View File
@@ -1,13 +1,44 @@
use std::ops::Add; use std::ops::Add;
pub fn combine_options<T>(a: Option<T>, b: Option<T>, f: impl FnOnce(T, T) -> T) -> Option<T> {
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<T>(a: Option<T>, b: Option<T>) -> Option<T> pub fn sum_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
where where
T: Add<Output = T>, T: Add<Output = T>,
{ {
match (a, b) { combine_options(a, b, |a, b| a + b)
(Some(a), Some(b)) => Some(a + b), }
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b), pub fn min_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
(None, None) => None, where
T: Ord,
{
combine_options(a, b, |a, b| a.min(b))
}
pub fn max_options<T>(a: Option<T>, b: Option<T>) -> Option<T>
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::<i32>(None, None), None);
assert_eq!(sum_options(Some(0.5), Some(0.25)), Some(0.75));
} }
} }
+82
View File
@@ -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() ((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);
}
}
@@ -24,3 +24,91 @@ pub fn update_metadata(state: &mut State, name: &str, desc: &str) -> Result<(),
}); });
Ok(()) 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<FileId> {
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"<gpx><trk></gpx>").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);
}
}
}
}
+141
View File
@@ -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<FileId> {
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);
}
}
@@ -37,8 +37,7 @@ impl StatisticsBuffer {
.unwrap_or_default(), .unwrap_or_default(),
); );
self.total_time.push( self.total_time.push(
sum_options(cumul_stats.total_time(), trkpt_stats.total_time) sum_options(cumul_stats.total_time, trkpt_stats.total_time).unwrap_or_default(),
.unwrap_or_default(),
); );
self.moving_time.push( self.moving_time.push(
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time) 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());
}
}
@@ -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<File>) {
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());
}
}
+70
View File
@@ -100,4 +100,74 @@ mod tests {
assert_eq!(engine.statistics_buffer.total_distance.len(), 80); 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"<gpx><trk></gpx>"
}));
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");
}
} }
+100 -1
View File
@@ -154,5 +154,104 @@ mod tests {
assert!(diff.modified.is_empty()); 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));
}
} }