mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-04 12:58:23 +00:00
progress
This commit is contained in:
@@ -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<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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<i32> {
|
||||
self.global.total_time()
|
||||
}
|
||||
|
||||
pub fn total_speed(&self) -> Option<f64> {
|
||||
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<f64>,
|
||||
pub total_time: Option<i32>,
|
||||
pub moving_time: Option<i32>,
|
||||
pub elevation_gain: f64,
|
||||
pub elevation_loss: f64,
|
||||
@@ -223,12 +221,8 @@ pub struct 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> {
|
||||
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> {
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
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>
|
||||
where
|
||||
T: Add<Output = T>,
|
||||
{
|
||||
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<T>(a: Option<T>, b: Option<T>) -> Option<T>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"<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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user