diff --git a/gpx-rs/engine/src/core/algorithm/timestamps.rs b/gpx-rs/engine/src/core/algorithm/timestamps.rs index f18e743e1..946650a15 100644 --- a/gpx-rs/engine/src/core/algorithm/timestamps.rs +++ b/gpx-rs/engine/src/core/algorithm/timestamps.rs @@ -248,9 +248,9 @@ mod tests { // 1 km in 10 s is 360 km/h let mut trkpt = timed(0..4, 0, 10_000); let km = distance(point(0, None).coordinates, point(1, None).coordinates); - let speed = Some(36.0 * km / 0.01); // the time of 1 point to the next is 100 s - replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), speed, None, false); - let expected = 10_000 + (3_600_000.0 * km / speed.unwrap()) as i64; + let speed = 36.0 * km / 0.01; // the time of 1 point to the next is 100 s + replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), Some(speed), None, false); + let expected = 10_000 + (3_600_000.0 * km / speed) as i64; assert_eq!(trkpt[2].time, Some(expected)); assert!(trkpt[2].time > trkpt[1].time); // the points after follow the new ones, they were not too early diff --git a/gpx-rs/engine/src/core/gpx/chunk.rs b/gpx-rs/engine/src/core/gpx/chunk.rs index f727e2429..7fffb5c5b 100644 --- a/gpx-rs/engine/src/core/gpx/chunk.rs +++ b/gpx-rs/engine/src/core/gpx/chunk.rs @@ -1,7 +1,7 @@ -use uuid::Uuid; - use crate::{Trackpoint, Waypoint}; +use super::common::uuid_id; + /// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk /// that is not touched by an edit is not copied. /// @@ -16,6 +16,9 @@ pub trait Chunk { /// A chunk with a new identity, holding `items`. fn new(items: Vec) -> Self; + /// Gives the chunk a new identity, after its items were changed in place. + fn renew(&mut self); + fn items(&self) -> &Vec; fn items_mut(&mut self) -> &mut Vec; @@ -27,14 +30,7 @@ pub trait Chunk { const MAX_TRKPT_CHUNK_SIZE: usize = 4096; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct TrackpointChunkId(pub Uuid); - -impl Default for TrackpointChunkId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(TrackpointChunkId); #[derive(Debug, Default)] pub struct TrackpointChunk { @@ -53,6 +49,10 @@ impl Chunk for TrackpointChunk { } } + fn renew(&mut self) { + self.id = Default::default(); + } + fn items(&self) -> &Vec { &self.trkpt } @@ -62,14 +62,7 @@ impl Chunk for TrackpointChunk { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct WaypointChunkId(pub Uuid); - -impl Default for WaypointChunkId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(WaypointChunkId); const MAX_WPT_CHUNK_SIZE: usize = 128; @@ -90,6 +83,10 @@ impl Chunk for WaypointChunk { } } + fn renew(&mut self) { + self.id = Default::default(); + } + fn items(&self) -> &Vec { &self.wpt } diff --git a/gpx-rs/engine/src/core/gpx/chunked.rs b/gpx-rs/engine/src/core/gpx/chunked.rs index 4e72d6824..a406d1df7 100644 --- a/gpx-rs/engine/src/core/gpx/chunked.rs +++ b/gpx-rs/engine/src/core/gpx/chunked.rs @@ -148,7 +148,10 @@ impl Chunked { fn replace_chunk(&mut self, chunk: usize, f: impl FnOnce(&mut Vec)) { let len = self.chunks[chunk].items().len(); match Rc::get_mut(&mut self.chunks[chunk]) { - Some(unique) => f(unique.items_mut()), + Some(unique) => { + f(unique.items_mut()); + unique.renew(); + } None => { let mut items = self.chunks[chunk].items().clone(); f(&mut items); @@ -291,7 +294,9 @@ impl<'a, C: Chunk> IntoIterator for &'a Chunked { pub struct ChunkedIter<'a, C: Chunk> { chunked: &'a Chunked, + /// The item returned last. idx: Option, + exhausted: bool, } impl Clone for ChunkedIter<'_, C> { @@ -299,6 +304,7 @@ impl Clone for ChunkedIter<'_, C> { Self { chunked: self.chunked, idx: self.idx, + exhausted: self.exhausted, } } } @@ -307,22 +313,307 @@ impl<'a, C: Chunk> ChunkedIter<'a, C> { pub fn new(chunked: &'a Chunked) -> Self { Self { chunked, - idx: Default::default(), + idx: None, + exhausted: false, } } + + fn move_to(&mut self, idx: Option) -> Option<&'a C::Item> { + self.exhausted = idx.is_none(); + self.idx = idx.or(self.idx); + idx.map(|idx| &self.chunked[idx]) + } } impl<'a, C: Chunk> Iterator for ChunkedIter<'a, C> { type Item = &'a C::Item; fn next(&mut self) -> Option { - self.idx = self.chunked.next_index(self.idx); - self.idx.map(|idx| &self.chunked[idx]) + if self.exhausted { + return None; + } + let next = self.chunked.next_index(self.idx); + self.move_to(next) } fn nth(&mut self, n: usize) -> Option { - let idx = self.idx.map_or_default(|idx| idx.flat) + n; - self.idx = self.chunked.locate(idx); - self.idx.map(|idx| &self.chunked[idx]) + if self.exhausted { + return None; + } + let target = self.idx.map_or(0, |idx| idx.flat + 1).saturating_add(n); + let next = self.chunked.locate(target); + self.move_to(next) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Chunks of at most 3 numbers, with an identity like the real ones. + #[derive(Debug)] + struct Numbers { + id: usize, + items: Vec, + } + + thread_local! { + static NEXT_ID: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + fn next_id() -> usize { + NEXT_ID.with(|id| id.replace(id.get() + 1)) + } + + impl Chunk for Numbers { + type Item = u32; + const MAX_SIZE: usize = 3; + + fn new(items: Vec) -> Self { + Self { + id: next_id(), + items, + } + } + + fn renew(&mut self) { + self.id = next_id(); + } + + fn items(&self) -> &Vec { + &self.items + } + + fn items_mut(&mut self) -> &mut Vec { + &mut self.items + } + } + + /// The numbers `0..n` in chunks of 3. + fn numbers(n: u32) -> Chunked { + let mut chunked = Chunked::default(); + for start in (0..n).step_by(3) { + chunked.push(Numbers::new((start..(start + 3).min(n)).collect())); + } + chunked + } + + fn values(chunked: &Chunked) -> Vec { + chunked.iter().copied().collect() + } + + fn ids(chunked: &Chunked) -> Vec { + chunked.chunks().iter().map(|chunk| chunk.id).collect() + } + + fn assert_consistent(chunked: &Chunked) { + let sizes: Vec = chunked.chunks().iter().map(|c| c.items.len()).collect(); + assert!( + sizes + .iter() + .all(|&size| 0 < size && size <= Numbers::MAX_SIZE) + ); + assert_eq!(chunked.len(), sizes.iter().sum::()); + for index in 0..chunked.len() { + assert_eq!(chunked.locate(index).unwrap().flat, index); + } + assert!(chunked.locate(chunked.len()).is_none()); + } + + #[test] + fn test_push_drops_empty_chunks() { + let mut chunked = numbers(4); + chunked.push(Numbers::new(vec![])); + assert_eq!(chunked.chunks().len(), 2); + assert_eq!(chunked.len(), 4); + assert!(Chunked::::default().is_empty()); + assert!(!chunked.is_empty()); + } + + #[test] + fn test_locate_index_and_iterate() { + let chunked = numbers(7); + assert_eq!( + chunked.locate(4), + Some(ChunkIndex { + chunk: 1, + pos: 1, + flat: 4 + }) + ); + assert_eq!(chunked[4], 4); + assert_eq!(chunked[chunked.locate(6).unwrap()], 6); + assert!(chunked.locate(7).is_none()); + assert_eq!(values(&chunked), (0..7).collect::>()); + assert_eq!(chunked.iter().count(), 7); + assert_eq!((&chunked).into_iter().last(), Some(&6)); + assert_eq!(values(&Chunked::default()), Vec::::new()); + } + + #[test] + fn test_first_last_next_and_previous_index() { + let chunked = numbers(7); + let first = chunked.first_index().unwrap(); + let last = chunked.last_index().unwrap(); + assert_eq!((first.flat, last.flat), (0, 6)); + assert_eq!((last.chunk, last.pos), (2, 0)); + + let mut forward = vec![]; + let mut cur = None; + while let Some(next) = chunked.next_index(cur) { + forward.push(chunked[next]); + cur = Some(next); + } + assert_eq!(forward, (0..7).collect::>()); + + let mut backward = vec![]; + let mut cur = None; + while let Some(prev) = chunked.prev_index(cur) { + backward.push(chunked[prev]); + cur = Some(prev); + } + assert_eq!(backward, (0..7).rev().collect::>()); + + let empty = Chunked::::default(); + assert!(empty.first_index().is_none() && empty.last_index().is_none()); + } + + #[test] + #[allow(clippy::iter_nth_zero)] + fn test_iterator_nth_goes_on_from_the_current_item() { + let chunked = numbers(10); + let mut iter = chunked.iter(); + assert_eq!(iter.nth(1), Some(&1)); + assert_eq!(iter.next(), Some(&2)); + // skips 3 and 4 + assert_eq!(iter.nth(2), Some(&5)); + assert_eq!(iter.nth(0), Some(&6)); + assert_eq!(iter.nth(2), Some(&9)); + assert_eq!(iter.nth(0), None); + assert_eq!(iter.next(), None, "an exhausted iterator stays exhausted"); + + assert_eq!(values(&numbers(10)).iter().skip(4).count(), 6); + assert_eq!( + chunked.iter().skip(4).copied().collect::>(), + (4..10).collect::>() + ); + assert_eq!( + chunked.iter().step_by(4).copied().collect::>(), + vec![0, 4, 8] + ); + } + + #[test] + fn test_splice_replaces_inserts_deletes_and_appends() { + let mut chunked = numbers(10); + chunked.splice(2, 5, vec![100, 101]); + assert_eq!(values(&chunked), [0, 1, 100, 101, 5, 6, 7, 8, 9]); + assert_consistent(&chunked); + + chunked.splice(0, 0, vec![50]); + assert_eq!(values(&chunked)[..3], [50, 0, 1]); + chunked.splice(chunked.len(), chunked.len(), vec![7, 7, 7, 7]); + assert_eq!(values(&chunked)[chunked.len() - 5..], [9, 7, 7, 7, 7]); + assert_consistent(&chunked); + + let len = chunked.len(); + chunked.splice(0, len, vec![]); + assert!(chunked.is_empty()); + assert_eq!(chunked.len(), 0); + } + + #[test] + #[should_panic(expected = "splice range out of bounds")] + fn test_splice_out_of_bounds_panics() { + numbers(4).splice(2, 5, vec![]); + } + + #[test] + fn test_splice_keeps_the_chunks_it_does_not_touch() { + let mut chunked = numbers(12); + let before = ids(&chunked); + // inside the second chunk + chunked.splice(4, 5, vec![40]); + let after = ids(&chunked); + assert_eq!(after.len(), before.len()); + assert_eq!( + (after[0], after[2], after[3]), + (before[0], before[2], before[3]) + ); + assert_ne!(after[1], before[1]); + } + + #[test] + fn test_edit_replaces_only_the_changed_chunks() { + let mut chunked = numbers(9); + let before = ids(&chunked); + + // nothing is changed: nothing is replaced + assert!(!chunked.edit(|n| *n == 4, |_| false)); + assert_eq!(ids(&chunked), before); + + // the filter picks the chunk, the hook changes it + assert!(chunked.edit( + |n| *n == 4, + |items| { + items[1] = 400; + true + } + )); + let after = ids(&chunked); + assert_eq!((after[0], after[2]), (before[0], before[2])); + assert_ne!(after[1], before[1]); + assert_eq!(values(&chunked), [0, 1, 2, 3, 400, 5, 6, 7, 8]); + + // a chunk left empty is dropped + assert!(chunked.edit( + |n| *n == 0, + |items| { + items.clear(); + true + } + )); + assert_eq!(chunked.chunks().len(), 2); + assert_consistent(&chunked); + } + + #[test] + fn test_update_copies_the_chunk_so_that_other_versions_do_not_change() { + let mut chunked = numbers(6); + let version = chunked.clone(); + let before = ids(&chunked); + chunked.update(4, |n| *n = 40); + assert_eq!(values(&chunked), [0, 1, 2, 3, 40, 5]); + assert_eq!(values(&version), (0..6).collect::>()); + assert_eq!(ids(&version), before); + assert_eq!(ids(&chunked)[0], before[0]); + assert_ne!(ids(&chunked)[1], before[1]); + } + + #[test] + fn test_updated_chunks_get_a_new_identity_even_when_nothing_shares_them() { + // what is derived from or kept of a chunk is keyed by its identity + let mut chunked = numbers(6); + let before = ids(&chunked); + chunked.update(1, |n| *n += 1); + assert_ne!(ids(&chunked)[0], before[0]); + assert_eq!(ids(&chunked)[1], before[1]); + + let before = ids(&chunked); + chunked.update_all(|_, n| *n += 1); + let after = ids(&chunked); + assert!(before.iter().zip(&after).all(|(a, b)| a != b)); + } + + #[test] + fn test_update_all_gives_the_index_of_each_item() { + let mut chunked = numbers(8); + let mut seen = vec![]; + chunked.update_all(|index, n| { + seen.push(index); + *n = 10 * index as u32; + }); + assert_eq!(seen, (0..8).collect::>()); + assert_eq!(values(&chunked), [0, 10, 20, 30, 40, 50, 60, 70]); } } diff --git a/gpx-rs/engine/src/core/gpx/common.rs b/gpx-rs/engine/src/core/gpx/common.rs index 0e10bf026..ef14fb5cf 100644 --- a/gpx-rs/engine/src/core/gpx/common.rs +++ b/gpx-rs/engine/src/core/gpx/common.rs @@ -1,3 +1,20 @@ +/// Defines an identifier made of a UUID, which is a new one by default. +macro_rules! uuid_id { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + pub struct $name(pub uuid::Uuid); + + impl Default for $name { + fn default() -> Self { + Self(uuid::Uuid::new_v4()) + } + } + }; +} + +pub(crate) use uuid_id; + #[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Link { pub href: String, diff --git a/gpx-rs/engine/src/core/gpx/file.rs b/gpx-rs/engine/src/core/gpx/file.rs index 99dcc6954..b1d00c2ea 100644 --- a/gpx-rs/engine/src/core/gpx/file.rs +++ b/gpx-rs/engine/src/core/gpx/file.rs @@ -1,15 +1,8 @@ -use uuid::Uuid; - use crate::{Link, Track, Waypoints}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct FileId(pub Uuid); +use super::common::uuid_id; -impl Default for FileId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(FileId); #[derive(Debug, Default, Clone)] pub struct File { diff --git a/gpx-rs/engine/src/core/gpx/segment.rs b/gpx-rs/engine/src/core/gpx/segment.rs index 02ad441ec..a1c54e54c 100644 --- a/gpx-rs/engine/src/core/gpx/segment.rs +++ b/gpx-rs/engine/src/core/gpx/segment.rs @@ -1,26 +1,13 @@ -use std::ops::{Deref, DerefMut}; - -use uuid::Uuid; +use std::ops::Deref; +use std::rc::Rc; use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct TrackSegmentId(pub Uuid); +use super::common::uuid_id; -impl Default for TrackSegmentId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(TrackSegmentId); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct TrackSegmentRevisionId(pub Uuid); - -impl Default for TrackSegmentRevisionId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(TrackSegmentRevisionId); #[derive(Debug, Default, Clone)] pub struct TrackSegment { @@ -42,17 +29,37 @@ impl Deref for TrackSegment { } } -impl DerefMut for TrackSegment { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.points - } -} - impl TrackSegment { + /// Adds a chunk after the last trackpoint, which is dropped if it is empty. + pub fn push(&mut self, chunk: TrackpointChunk) { + self.points.push(chunk); + self.rev_id = Default::default(); + } + + /// Adds a chunk that is shared with something else after the last trackpoint. + pub fn push_shared(&mut self, chunk: Rc) { + self.points.push_shared(chunk); + self.rev_id = Default::default(); + } + + /// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no + /// such trackpoint. + pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) { + self.points.update(index, f); + self.rev_id = Default::default(); + } + + /// Applies `f` to every trackpoint, with its index, see [`Chunked::update_all`]. + pub fn update_all(&mut self, f: impl FnMut(usize, &mut Trackpoint)) { + self.points.update_all(f); + self.rev_id = Default::default(); + } + /// Replaces the points in `start..end` by `points`, see [`Chunked::splice`]. The first and /// last trackpoints are anchors afterwards. pub fn splice(&mut self, start: usize, end: usize, points: Vec) { self.points.splice(start, end, points); + self.rev_id = Default::default(); self.ensure_end_anchors(); } @@ -74,7 +81,7 @@ impl TrackSegment { /// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if /// there is no such trackpoint. pub fn set_anchor(&mut self, index: usize, zoom: u8) { - self.points.update(index, |trkpt| trkpt.anchor = Some(zoom)); + self.update(index, |trkpt| trkpt.anchor = Some(zoom)); } /// Sets the anchors of the trackpoints from the details of the path of the segment (see @@ -82,7 +89,7 @@ impl TrackSegment { pub fn compute_anchors(&mut self) { let anchors = compute_anchors(self); let mut anchors = anchors.into_iter().peekable(); - self.points.update_all(|index, trkpt| { + self.update_all(|index, trkpt| { trkpt.anchor = match anchors.peek() { Some(&(anchor, zoom)) if anchor == index => { anchors.next(); @@ -98,6 +105,8 @@ impl TrackSegment { mod tests { use std::rc::Rc; + use crate::Chunk; + use super::*; fn create_track_segment(nb_chunks: usize) -> TrackSegment { @@ -354,4 +363,49 @@ mod tests { idx = next; } } + + fn points_of(n: usize) -> Vec { + (0..n).map(|_| Trackpoint::default()).collect() + } + + #[test] + fn test_every_change_gives_a_new_revision() { + type Change = (&'static str, fn(&mut TrackSegment)); + let changes: [Change; 7] = [ + ("push", |s| s.push(TrackpointChunk::new(points_of(1)))), + ("push_shared", |s| { + s.push_shared(Rc::new(TrackpointChunk::new(points_of(1)))) + }), + ("update", |s| s.update(0, |p| p.ele += 1.0)), + ("update_all", |s| s.update_all(|_, p| p.ele += 1.0)), + ("splice", |s| s.splice(1, 2, points_of(2))), + ("set_anchor", |s| s.set_anchor(1, 5)), + ("compute_anchors", |s| s.compute_anchors()), + ]; + for (name, change) in changes { + let mut trkseg = create_track_segment(3); + let before = trkseg.rev_id; + change(&mut trkseg); + assert_ne!(trkseg.rev_id, before, "{name}"); + } + // reading does not + let trkseg = create_track_segment(3); + let before = trkseg.rev_id; + let _ = trkseg.iter().count(); + assert_eq!(trkseg.rev_id, before); + } + + #[test] + fn test_ends_are_anchors_after_a_splice() { + let mut trkseg = create_track_segment(3); + trkseg.splice(0, 1, points_of(2)); + let last = trkseg.len() - 1; + assert_eq!(trkseg[0].anchor, Some(0)); + assert_eq!(trkseg[last].anchor, Some(0)); + + // nothing to anchor in an empty segment + let mut empty = TrackSegment::default(); + empty.ensure_end_anchors(); + assert!(empty.is_empty()); + } } diff --git a/gpx-rs/engine/src/core/gpx/track.rs b/gpx-rs/engine/src/core/gpx/track.rs index f9bdebb38..9a3c06358 100644 --- a/gpx-rs/engine/src/core/gpx/track.rs +++ b/gpx-rs/engine/src/core/gpx/track.rs @@ -1,15 +1,8 @@ -use uuid::Uuid; - use crate::{Link, TrackSegment}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct TrackId(pub Uuid); +use super::common::uuid_id; -impl Default for TrackId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(TrackId); #[derive(Debug, Default, Clone)] pub struct Track { diff --git a/gpx-rs/engine/src/core/gpx/waypoint.rs b/gpx-rs/engine/src/core/gpx/waypoint.rs index 409e46ccc..b3cda01f9 100644 --- a/gpx-rs/engine/src/core/gpx/waypoint.rs +++ b/gpx-rs/engine/src/core/gpx/waypoint.rs @@ -1,15 +1,8 @@ -use uuid::Uuid; - use crate::{Link, LngLat}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct WaypointId(pub Uuid); +use super::common::uuid_id; -impl Default for WaypointId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(WaypointId); #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] pub struct Waypoint { diff --git a/gpx-rs/engine/src/core/gpx/waypoints.rs b/gpx-rs/engine/src/core/gpx/waypoints.rs index 361de4d2a..3be828831 100644 --- a/gpx-rs/engine/src/core/gpx/waypoints.rs +++ b/gpx-rs/engine/src/core/gpx/waypoints.rs @@ -1,17 +1,10 @@ use std::ops::Deref; -use uuid::Uuid; - use crate::{Chunked, Waypoint, WaypointChunk, WaypointId}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct FileWaypointsRevisionId(pub Uuid); +use super::common::uuid_id; -impl Default for FileWaypointsRevisionId { - fn default() -> Self { - Self(Uuid::new_v4()) - } -} +uuid_id!(FileWaypointsRevisionId); /// The waypoints of a file, in chunks that are shared between the versions of the file (see /// [`Chunked`]). diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index eac38d977..5838a6b41 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -198,25 +198,25 @@ fn apply_text( Some(GPXElement::Surface) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { - trkpt.surface = categories.surface.code(&e); + trkpt.surface = categories.surface.code(e); } } Some(GPXElement::Highway) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { - trkpt.highway = categories.highway.code(&e); + trkpt.highway = categories.highway.code(e); } } Some(GPXElement::SacScale) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { - trkpt.sac_scale = categories.sac_scale.code(&e); + trkpt.sac_scale = categories.sac_scale.code(e); } } Some(GPXElement::MtbScale) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { - trkpt.mtb_scale = categories.mtb_scale.code(&e); + trkpt.mtb_scale = categories.mtb_scale.code(e); } } Some(GPXElement::Symbol) => { diff --git a/gpx-rs/engine/src/engine/command/apply.rs b/gpx-rs/engine/src/engine/command/apply.rs index 2edd1193d..108a0b9e9 100644 --- a/gpx-rs/engine/src/engine/command/apply.rs +++ b/gpx-rs/engine/src/engine/command/apply.rs @@ -12,8 +12,6 @@ pub trait Apply { #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandError { - /// The command is not implemented yet. - NotImplemented(&'static str), /// The command would not change anything. NothingToDo, /// The input data could not be parsed. @@ -23,7 +21,6 @@ pub enum CommandError { impl fmt::Display for CommandError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::NotImplemented(name) => write!(f, "{name} is not implemented yet"), Self::NothingToDo => write!(f, "nothing to do"), Self::InvalidData(err) => write!(f, "invalid data: {err}"), } diff --git a/gpx-rs/engine/src/engine/command/file/paste.rs b/gpx-rs/engine/src/engine/command/file/paste.rs index c49396158..ad84279a7 100644 --- a/gpx-rs/engine/src/engine/command/file/paste.rs +++ b/gpx-rs/engine/src/engine/command/file/paste.rs @@ -719,8 +719,6 @@ mod tests { let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2)); let b = add(&mut fx, file("b", &[], 0)); let t = track_ids(&fx, a); - let s = segment_ids(&fx, a, 0); - let w = wpt_ids(&fx, a); copy(&mut fx, tracks(a, &t), false); fx.selection = files(&[b]); @@ -736,7 +734,7 @@ mod tests { let mut fx = Fixture::default(); let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2)); let b = add(&mut fx, file("b", &[], 0)); - let (t, s, w) = (track_ids(&fx, a), segment_ids(&fx, a, 0), wpt_ids(&fx, a)); + let (t, s) = (track_ids(&fx, a), segment_ids(&fx, a, 0)); copy(&mut fx, segments(a, t[0], &s), false); fx.selection = files(&[b]); delete_file(&mut fx, a); diff --git a/gpx-rs/engine/src/engine/command/file/transfer.rs b/gpx-rs/engine/src/engine/command/file/transfer.rs index fdd85ecf0..b70f5ef87 100644 --- a/gpx-rs/engine/src/engine/command/file/transfer.rs +++ b/gpx-rs/engine/src/engine/command/file/transfer.rs @@ -148,16 +148,19 @@ pub fn transfer( .is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)), Destination::Waypoints(file_id, _) => state.files.contains_key(&file_id), }; - let valid = match (content, to) { + let valid = matches!( + (content, to), (ClipboardContent::Files(_), Destination::Files(_)) - | (ClipboardContent::Tracks(_), Destination::Files(_) | Destination::Tracks(..)) - | ( - ClipboardContent::Segments(_), - Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..), - ) - | (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) => true, - _ => false, - }; + | ( + ClipboardContent::Tracks(_), + Destination::Files(_) | Destination::Tracks(..) + ) + | ( + ClipboardContent::Segments(_), + Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..), + ) + | (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) + ); let empty = match content { ClipboardContent::Files(files) => files.is_empty(), ClipboardContent::Tracks(tracks) => tracks.is_empty(), diff --git a/gpx-rs/engine/src/engine/command/pattern/mod.rs b/gpx-rs/engine/src/engine/command/pattern/mod.rs index f4f58aaee..07aa6a615 100644 --- a/gpx-rs/engine/src/engine/command/pattern/mod.rs +++ b/gpx-rs/engine/src/engine/command/pattern/mod.rs @@ -1,9 +1,11 @@ mod copy; mod produce; +mod update_segments; mod update_selected; mod update_waypoint; pub use copy::*; pub use produce::*; +pub use update_segments::*; pub use update_selected::*; pub use update_waypoint::*; diff --git a/gpx-rs/engine/src/engine/command/pattern/update_segments.rs b/gpx-rs/engine/src/engine/command/pattern/update_segments.rs new file mode 100644 index 000000000..868ac263d --- /dev/null +++ b/gpx-rs/engine/src/engine/command/pattern/update_segments.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use crate::{File, FileId, SegmentLocation, State, TrackSegment}; + +/// Edits the segments at `locations`, calling `f` with the position of each location in the slice +/// and its segment, in that order. +/// +/// Each file concerned is copied once (cheap, trackpoints are shared chunks) and stored back as a +/// new snapshot. The segments take care of their own revision (see [`TrackSegment`]). +pub fn update_segments( + state: &mut State, + locations: &[SegmentLocation], + mut f: impl FnMut(usize, &mut TrackSegment), +) { + let mut files: HashMap = HashMap::new(); + for (i, location) in locations.iter().enumerate() { + let file = files + .entry(location.file_id) + .or_insert_with(|| (*state.files[&location.file_id]).clone()); + f(i, &mut file.trk[location.trk].trkseg[location.seg]); + } + for (file_id, file) in files { + state.files.insert(file_id, Rc::new(file)); + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::engine::command::fixture::Fixture; + + use super::*; + + fn fixture() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + let file = Rc::new(crate::parse(&data, &mut Default::default()).unwrap()); + let id = file.id; + fx.files.insert(id, file); + (fx, id) + } + + #[test] + fn test_only_the_given_segments_are_edited_in_order() { + let (mut fx, id) = fixture(); + let before = fx.files[&id].clone(); + let locations = [ + SegmentLocation { + file_id: id, + trk: 1, + seg: 0, + }, + SegmentLocation { + file_id: id, + trk: 0, + seg: 0, + }, + ]; + let mut calls = vec![]; + update_segments(&mut fx.state(), &locations, |i, segment| { + calls.push(i); + segment.update(0, |trkpt| trkpt.ele = 1234.0); + }); + + assert_eq!(calls, vec![0, 1]); + let after = &fx.files[&id]; + assert!(!Rc::ptr_eq(after, &before)); + for (trk, seg) in [(1, 0), (0, 0)] { + assert_eq!(after.trk[trk].trkseg[seg][0].ele, 1234.0); + assert_ne!( + after.trk[trk].trkseg[seg].rev_id, + before.trk[trk].trkseg[seg].rev_id + ); + } + // the other segments are the same ones + assert_eq!( + after.trk[0].trkseg[1].rev_id, + before.trk[0].trkseg[1].rev_id + ); + } + + #[test] + fn test_no_location_leaves_the_files_alone() { + let (mut fx, id) = fixture(); + let before = fx.files[&id].clone(); + update_segments(&mut fx.state(), &[], |_, _| panic!("nothing to edit")); + assert!(Rc::ptr_eq(&fx.files[&id], &before)); + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/elevation.rs b/gpx-rs/engine/src/engine/command/tools/elevation.rs index 6d6828e56..48dcf9655 100644 --- a/gpx-rs/engine/src/engine/command/tools/elevation.rs +++ b/gpx-rs/engine/src/engine/command/tools/elevation.rs @@ -1,6 +1,4 @@ -use std::rc::Rc; - -use crate::{Apply, CommandError, File, State}; +use crate::{Apply, CommandError, State, update_segments}; /// Sets the elevation of the trackpoints of the selected segments (the ones of the statistics of /// the selection, in the same order): `ele` has one elevation per trackpoint. @@ -31,17 +29,9 @@ impl Apply for Elevation<'_> { } let mut ele = self.ele.iter().copied(); - // the segments of a file are consecutive - for group in locations.chunk_by(|a, b| a.file_id == b.file_id) { - let file_id = group[0].file_id; - let mut file: File = (*state.files[&file_id]).clone(); - for location in group { - let segment = &mut file.trk[location.trk].trkseg[location.seg]; - segment.update_all(|_, pt| pt.ele = ele.next().unwrap_or(pt.ele)); - segment.rev_id = Default::default(); - } - state.files.insert(file_id, Rc::new(file)); - } + update_segments(state, &locations, |_, segment| { + segment.update_all(|_, pt| pt.ele = ele.next().unwrap_or(pt.ele)); + }); Ok(()) } } diff --git a/gpx-rs/engine/src/engine/command/tools/merge.rs b/gpx-rs/engine/src/engine/command/tools/merge.rs index 68907bc4e..130ce5a68 100644 --- a/gpx-rs/engine/src/engine/command/tools/merge.rs +++ b/gpx-rs/engine/src/engine/command/tools/merge.rs @@ -418,11 +418,13 @@ mod tests { } fx.files.insert(b, Rc::new(file)); let gap = |fx: &Fixture, remove_gaps: bool| { - let mut fx2 = Fixture::default(); - fx2.files = fx.files.clone(); - fx2.order = crate::FileOrder(fx.order.0.clone()); - fx2.selection = Selection::File { - file_ids: [a, b].into(), + let mut fx2 = Fixture { + files: fx.files.clone(), + order: crate::FileOrder(fx.order.0.clone()), + selection: Selection::File { + file_ids: [a, b].into(), + }, + ..Default::default() }; Merge { type_: MergeType::Connect, diff --git a/gpx-rs/engine/src/engine/command/tools/reduce.rs b/gpx-rs/engine/src/engine/command/tools/reduce.rs index 20612424d..856f68c73 100644 --- a/gpx-rs/engine/src/engine/command/tools/reduce.rs +++ b/gpx-rs/engine/src/engine/command/tools/reduce.rs @@ -1,6 +1,4 @@ -use std::rc::Rc; - -use crate::{Apply, CommandError, File, State, Trackpoint, reduce_indices}; +use crate::{Apply, CommandError, State, reduce_indices, update_segments}; /// Removes the trackpoints of the selected segments that are less than `tolerance` meters away /// from the line of their neighbours (Ramer-Douglas-Peucker, see [`reduce_indices`]). The ends of the segments are @@ -22,32 +20,25 @@ impl Apply for Reduce { let locations = state .selection .segment_locations(state.files, &state.order.0); - let mut reduced = vec![]; + let mut changed = vec![]; + let mut kept_points = vec![]; for location in &locations { let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg]; let kept = reduce_indices(segment, self.tolerance); if kept.len() == segment.len() { continue; } - let points: Vec = kept.iter().map(|&i| segment[i].clone()).collect(); - reduced.push((*location, points)); + changed.push(*location); + kept_points.push(kept.iter().map(|&i| segment[i].clone()).collect::>()); } - if reduced.is_empty() { + if changed.is_empty() { return Err(CommandError::NothingToDo); } - // the segments of a file are consecutive - for group in reduced.chunk_by(|a, b| a.0.file_id == b.0.file_id) { - let file_id = group[0].0.file_id; - let mut file: File = (*state.files[&file_id]).clone(); - for (location, points) in group { - let segment = &mut file.trk[location.trk].trkseg[location.seg]; - let len = segment.len(); - segment.splice(0, len, points.clone()); - segment.rev_id = Default::default(); - } - state.files.insert(file_id, Rc::new(file)); - } + update_segments(state, &changed, |i, segment| { + let len = segment.len(); + segment.splice(0, len, std::mem::take(&mut kept_points[i])); + }); Ok(()) } } diff --git a/gpx-rs/engine/src/engine/command/tools/round_trip.rs b/gpx-rs/engine/src/engine/command/tools/round_trip.rs index 98548633b..8f609a842 100644 --- a/gpx-rs/engine/src/engine/command/tools/round_trip.rs +++ b/gpx-rs/engine/src/engine/command/tools/round_trip.rs @@ -1,6 +1,4 @@ -use std::{collections::HashMap, rc::Rc}; - -use crate::{Apply, CommandError, File, FileId, State, round_trip}; +use crate::{Apply, CommandError, State, round_trip, update_segments}; /// Makes each selected segment come back to where it started: the segments of the selected files /// and tracks, and the selected segments, get a reversed copy of themselves after their last @@ -12,26 +10,16 @@ pub struct RoundTrip; impl Apply for RoundTrip { fn apply(self, state: &mut State) -> Result<(), CommandError> { - let mut files: HashMap = HashMap::new(); - for location in state + let locations: Vec<_> = state .selection .segment_locations(state.files, &state.order.0) - { - let (file_id, trk, seg) = (location.file_id, location.trk, location.seg); - if state.files[&file_id].trk[trk].trkseg[seg].len() < 2 { - continue; - } - let file = files - .entry(file_id) - .or_insert_with(|| (*state.files[&file_id]).clone()); - round_trip(&mut file.trk[trk].trkseg[seg]); - } - if files.is_empty() { + .into_iter() + .filter(|l| state.files[&l.file_id].trk[l.trk].trkseg[l.seg].len() >= 2) + .collect(); + if locations.is_empty() { return Err(CommandError::NothingToDo); } - for (file_id, file) in files { - state.files.insert(file_id, Rc::new(file)); - } + update_segments(state, &locations, |_, segment| round_trip(segment)); Ok(()) } } @@ -39,8 +27,11 @@ impl Apply for RoundTrip { #[cfg(test)] mod tests { use std::collections::HashSet; + use std::rc::Rc; - use crate::{Load, Selection, TrackSegment, end_time, engine::command::fixture::Fixture}; + use crate::{ + FileId, Load, Selection, TrackSegment, end_time, engine::command::fixture::Fixture, + }; use super::*; diff --git a/gpx-rs/engine/src/engine/command/tools/route.rs b/gpx-rs/engine/src/engine/command/tools/route.rs index 892c8aa11..854dbef6e 100644 --- a/gpx-rs/engine/src/engine/command/tools/route.rs +++ b/gpx-rs/engine/src/engine/command/tools/route.rs @@ -397,7 +397,7 @@ mod tests { } } - fn segment<'a>(fx: &'a Fixture, id: FileId, trk: usize, seg: usize) -> &'a TrackSegment { + fn segment(fx: &Fixture, id: FileId, trk: usize, seg: usize) -> &TrackSegment { &fx.files[&id].trk[trk].trkseg[seg] } diff --git a/gpx-rs/engine/src/engine/command/tools/time.rs b/gpx-rs/engine/src/engine/command/tools/time.rs index aaa290db3..1bf4602a0 100644 --- a/gpx-rs/engine/src/engine/command/tools/time.rs +++ b/gpx-rs/engine/src/engine/command/tools/time.rs @@ -1,8 +1,7 @@ -use std::rc::Rc; - use crate::{ - Apply, CommandError, File, SegmentLocation, State, Statistics, TrackSegment, Trackpoint, - artificial_weights, shifted_and_compressed, with_artificial_timestamps, with_timestamps, + Apply, CommandError, SegmentLocation, State, Statistics, TrackSegment, Trackpoint, + artificial_weights, shifted_and_compressed, update_segments, with_artificial_timestamps, + with_timestamps, }; /// How the timestamps of the selection are set. @@ -94,7 +93,7 @@ impl Apply for Time { }) .collect(); let mut total: f64 = weights.iter().flatten().sum(); - if !(total > 0.0) { + if total.is_nan() || total <= 0.0 { // no distance to share the time by: every interval takes as long for weights in &mut weights { weights.iter_mut().for_each(|w| *w = 1.0); @@ -122,19 +121,10 @@ impl Apply for Time { } } - // the segments of a file are consecutive - let mut times = times.into_iter(); - for group in locations.chunk_by(|a, b| a.file_id == b.file_id) { - let file_id = group[0].file_id; - let mut file: File = (*state.files[&file_id]).clone(); - for location in group { - let times = times.next().unwrap_or_default(); - let segment = &mut file.trk[location.trk].trkseg[location.seg]; - segment.update_all(|i, pt| pt.time = times.get(i).copied().flatten()); - segment.rev_id = Default::default(); - } - state.files.insert(file_id, Rc::new(file)); - } + update_segments(state, &locations, |i, segment| { + let times = ×[i]; + segment.update_all(|j, pt| pt.time = times.get(j).copied().flatten()); + }); Ok(()) } } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 5460d4440..80661f4d1 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -446,7 +446,7 @@ mod tests { } #[test] - fn test_not_implemented_command_is_rejected() { + fn test_command_with_nothing_to_do_is_rejected() { let mut engine = Engine::default(); new(&mut engine, "file"); assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); @@ -754,7 +754,7 @@ mod tests { )); assert!(!select_elements(&mut engine, node.clone(), SelectMode::Add)); assert_eq!(engine.selection(), &node); - assert!(engine.selection_changed() == false); + assert!(!engine.selection_changed()); // unknown elements and files are ignored when adding let unknown = tracks(&[TrackId::default()]); @@ -1429,4 +1429,129 @@ mod tests { assert!(engine.trackpoint(&file, &seg_id, len).is_none()); assert!(engine.trackpoint(&Default::default(), &seg_id, 0).is_none()); } + + #[test] + fn test_export_writes_the_file_as_it_is() { + let mut engine = Engine::default(); + assert!( + engine + .export(&FileId::default(), ExportOptions::ALL) + .is_none() + ); + + load(&mut engine, "data/with_time.gpx"); + let id = engine.order()[0]; + let bytes = engine.export(&id, ExportOptions::ALL).unwrap(); + let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap(); + let file = engine.stack.current().unwrap()[&id].clone(); + assert_eq!(again.trk.len(), file.trk.len()); + assert_eq!(again.trk[0].trkseg[0].len(), file.trk[0].trkseg[0].len()); + assert!(again.trk[0].trkseg[0][0].time.is_some()); + + // the options leave data out + let bytes = engine.export(&id, ExportOptions::NONE).unwrap(); + let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap(); + assert!(again.trk[0].trkseg[0].iter().all(|p| p.time.is_none())); + + // a file that is gone cannot be exported + engine.execute(Action::Edit(Command::DeleteAll(DeleteAll))); + assert!(engine.export(&id, ExportOptions::ALL).is_none()); + } + + #[test] + fn test_exportable_data_is_the_union_over_the_files() { + let mut engine = Engine::default(); + assert_eq!(engine.exportable_data(&[]), ExportOptions::NONE); + + load(&mut engine, "data/simple.gpx"); + load(&mut engine, "data/with_time.gpx"); + let (plain, timed) = (engine.order()[0], engine.order()[1]); + assert!(!engine.exportable_data(&[plain]).time); + assert!(engine.exportable_data(&[timed]).time); + assert!(engine.exportable_data(&[plain, timed]).time); + // unknown files have nothing + assert_eq!( + engine.exportable_data(&[FileId::default()]), + ExportOptions::NONE + ); + assert!(!engine.exportable_data(&[plain, FileId::default()]).time); + } + + #[test] + fn test_file_statistics_are_the_ones_of_the_whole_file() { + let mut engine = Engine::default(); + assert!(engine.file_statistics(&FileId::default()).is_none()); + + load(&mut engine, "data/simple.gpx"); + let id = engine.order()[0]; + let stats = engine.file_statistics(&id).unwrap(); + assert!(stats.total_distance > 0.0); + let last = engine.statistics().total_distance.last().copied().unwrap(); + assert!((stats.total_distance - last).abs() < 1e-9); + assert!(engine.file_statistics(&FileId::default()).is_none()); + + // statistics of a file do not depend on the selection + engine.execute(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert_eq!( + engine.file_statistics(&id).unwrap().total_distance, + stats.total_distance + ); + + engine.execute(Action::Undo); + assert!(engine.file_statistics(&id).is_none()); + } + + #[test] + fn test_reduction_distances_follow_the_selection() { + let mut engine = Engine::default(); + assert!(engine.reduction_distances().is_empty()); + + load(&mut engine, "data/simple.gpx"); + let n = engine.statistics().total_distance.len(); + let distances = engine.reduction_distances(); + assert_eq!(distances.len(), n); + assert!(distances.iter().all(|d| !d.is_nan())); + + engine.execute(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert!(engine.reduction_distances().is_empty()); + } + + #[test] + fn test_routing_describes_the_anchors_of_the_selection() { + let mut engine = Engine::default(); + assert!(engine.routing().anchor_indices.is_empty()); + + load(&mut engine, "data/simple.gpx"); + let n = engine.statistics().total_distance.len() as u32; + let routing = engine.routing(); + assert_eq!(routing.segment_starts, vec![0]); + assert_eq!(routing.segment_ids.len(), 1); + // the ends of a segment are always anchors + assert_eq!(routing.anchor_indices.first(), Some(&0)); + assert_eq!(routing.anchor_indices.last(), Some(&(n - 1))); + assert_eq!(routing.anchor_indices.len(), routing.anchor_zooms.len()); + assert_eq!(routing.anchor_zooms[0], 0); + + // the revision follows what the indices refer to + let revision = routing.revision; + engine.execute(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert!(engine.routing().anchor_indices.is_empty()); + assert_ne!(engine.routing().revision, revision); + // nothing changed for the routing: same revision + let revision = engine.routing().revision; + engine.execute(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert_eq!(engine.routing().revision, revision); + } } diff --git a/gpx-rs/engine/src/engine/state/selection.rs b/gpx-rs/engine/src/engine/state/selection.rs index a9689680c..e5019388a 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -244,13 +244,10 @@ impl Selection { } /// Position of the first and last of the `selected` ids among the `all` ids. -fn selected_range( +fn selected_range( all: impl Iterator, selected: &HashSet, -) -> Option<(usize, usize)> -where - T: std::hash::Hash + Eq, -{ +) -> Option<(usize, usize)> { let mut range: Option<(usize, usize)> = None; for (i, id) in all.enumerate() { if selected.contains(&id) { @@ -587,7 +584,7 @@ mod tests { wpt: vec![Waypoint::default()], ..Default::default() }); - let tree = Tree { + Tree { file: file.id, tracks: file.trk.iter().map(|t| t.id).collect(), segments: file @@ -598,8 +595,7 @@ mod tests { waypoints: file.wpt.iter().map(|w| w.id).collect(), order: vec![file.id], files: StackEntry::from([(file.id, Rc::new(file))]), - }; - tree + } } #[test] diff --git a/gpx-rs/engine/src/storage/codec.rs b/gpx-rs/engine/src/storage/codec.rs index ec4aa8b7e..bd151a2c4 100644 --- a/gpx-rs/engine/src/storage/codec.rs +++ b/gpx-rs/engine/src/storage/codec.rs @@ -128,3 +128,159 @@ pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Option { wpt: decode::>(bytes)?, }) } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::Chunk; + + use super::*; + + fn trackpoints(n: usize) -> TrackpointChunk { + TrackpointChunk::new( + (0..n) + .map(|i| Trackpoint { + ele: i as f64, + ..Default::default() + }) + .collect(), + ) + } + + #[test] + fn test_encode_decode_round_trip() { + let value = vec![(1_u32, "a".to_string()), (2, "b".to_string())]; + let bytes = encode(&value); + assert_eq!(bytes[0], VERSION); + assert_eq!(decode::>(&bytes), Some(value)); + } + + #[test] + fn test_decode_rejects_other_versions_and_garbage() { + let mut bytes = encode(&42_u32); + assert_eq!(decode::(&bytes), Some(42)); + bytes[0] = VERSION + 1; + assert_eq!(decode::(&bytes), None); + assert_eq!(decode::(&[]), None); + // the right version, but not what was asked for + assert_eq!(decode::>(&[VERSION, 0xff, 0xff, 0xff]), None); + assert_eq!(decode::(&[VERSION]), None); + } + + #[test] + fn test_chunks_keep_their_identity_and_items() { + let chunk = trackpoints(3); + let decoded = decode_trackpoints(chunk.id.0, &encode_trackpoints(&chunk)).unwrap(); + assert_eq!(decoded.id, chunk.id); + let eles: Vec = decoded.trkpt.iter().map(|p| p.ele).collect(); + assert_eq!(eles, [0.0, 1.0, 2.0]); + assert!(decode_trackpoints(chunk.id.0, &[VERSION, 9, 9]).is_none()); + + let chunk = WaypointChunk::new(vec![Waypoint { + name: Some("summit".into()), + ..Default::default() + }]); + let decoded = decode_waypoints(chunk.id.0, &encode_waypoints(&chunk)).unwrap(); + assert_eq!(decoded.id, chunk.id); + assert_eq!(decoded.wpt[0].name.as_deref(), Some("summit")); + assert!(decode_waypoints(chunk.id.0, &[]).is_none()); + } + + fn file() -> File { + let mut file = File::default(); + file.info.name = "file".into(); + for name in ["a", "b"] { + let mut track = Track::default(); + track.info.name = Some(name.into()); + let mut segment = TrackSegment::default(); + segment.push(trackpoints(2)); + segment.push(trackpoints(1)); + track.trkseg.push(segment); + track.trkseg.push(TrackSegment::default()); + file.trk.push(track); + } + file.wpt.push(WaypointChunk::new(vec![Waypoint::default()])); + file + } + + /// The chunks of a file, by identity. + fn chunks_of( + file: &File, + ) -> ( + HashMap>, + HashMap>, + ) { + let trackpoints = file + .trk + .iter() + .flat_map(|track| &track.trkseg) + .flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone()))) + .collect(); + let waypoints = file + .wpt + .chunks() + .iter() + .map(|c| (c.id, c.clone())) + .collect(); + (trackpoints, waypoints) + } + + #[test] + fn test_file_record_rebuilds_the_file_with_the_same_chunks() { + let file = file(); + let (trackpoints, waypoints) = chunks_of(&file); + let record: FileRecord = decode(&encode(&FileRecord::new(&file))).unwrap(); + let built = record + .build(file.id, &|id| trackpoints.get(id).cloned(), &|id| { + waypoints.get(id).cloned() + }) + .unwrap(); + + assert_eq!(built.id, file.id); + assert_eq!(built.info, file.info); + assert_eq!(built.trk.len(), 2); + for (built, original) in built.trk.iter().zip(&file.trk) { + assert_eq!(built.id, original.id); + assert_eq!(built.info, original.info); + assert_eq!(built.trkseg.len(), 2); + for (built, original) in built.trkseg.iter().zip(&original.trkseg) { + assert_eq!(built.id, original.id); + assert_eq!(built.len(), original.len()); + for (a, b) in built.chunks().iter().zip(original.chunks()) { + // the chunks are the ones that were given, not copies + assert!(Rc::ptr_eq(a, b)); + } + } + } + assert_eq!(built.wpt.len(), 1); + assert!(Rc::ptr_eq(&built.wpt.chunks()[0], &file.wpt.chunks()[0])); + } + + #[test] + fn test_file_record_of_a_missing_chunk_builds_nothing() { + let file = file(); + let (trackpoints, waypoints) = chunks_of(&file); + + let record = || FileRecord::new(&file); + assert!( + record() + .build(file.id, &|_| None, &|id| waypoints.get(id).cloned()) + .is_none() + ); + assert!( + record() + .build(file.id, &|id| trackpoints.get(id).cloned(), &|_| None) + .is_none() + ); + } + + #[test] + fn test_file_record_without_data_builds_an_empty_file() { + let file = File::default(); + let built = FileRecord::new(&file) + .build(file.id, &|_| None, &|_| None) + .unwrap(); + assert!(built.trk.is_empty() && built.wpt.is_empty()); + } +} diff --git a/gpx-rs/engine/src/storage/memory.rs b/gpx-rs/engine/src/storage/memory.rs index ce9986684..a1cc20c6d 100644 --- a/gpx-rs/engine/src/storage/memory.rs +++ b/gpx-rs/engine/src/storage/memory.rs @@ -103,3 +103,110 @@ impl Storage for MemoryStorage { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::task::{Context, Poll, Waker}; + + use super::*; + + fn block_on(future: F) -> F::Output { + let mut future = std::pin::pin!(future); + let mut context = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => value, + Poll::Pending => panic!("the memory storage does not wait"), + } + } + + #[test] + fn test_empty_storage_has_nothing() { + let storage = MemoryStorage::default(); + let data = block_on(storage.load()).unwrap(); + assert_eq!(data, StoredData::default()); + assert_eq!(storage.counts(), (0, 0, 0)); + assert!(block_on(storage.load_settings()).unwrap().is_empty()); + } + + #[test] + fn test_commit_puts_and_deletes() { + let storage = MemoryStorage::default(); + let (a, b) = (FileId::default(), FileId::default()); + let (t, w) = (Uuid::new_v4(), Uuid::new_v4()); + block_on(storage.commit(&Batch { + put_files: vec![(a, vec![1]), (b, vec![2])], + put_trackpoint_chunks: vec![(t, vec![3])], + put_waypoint_chunks: vec![(w, vec![4])], + order: Some(vec![5]), + categories: Some(vec![6]), + ..Default::default() + })) + .unwrap(); + assert_eq!(storage.counts(), (2, 1, 1)); + + let mut data = block_on(storage.load()).unwrap(); + data.files.sort_by_key(|(_, bytes)| bytes.clone()); + assert_eq!(data.files, vec![(a, vec![1]), (b, vec![2])]); + assert_eq!(data.trackpoint_chunks, vec![(t, vec![3])]); + assert_eq!(data.waypoint_chunks, vec![(w, vec![4])]); + assert_eq!( + (data.order, data.categories), + (Some(vec![5]), Some(vec![6])) + ); + + // a put replaces, a delete removes, what is not mentioned stays + block_on(storage.commit(&Batch { + put_files: vec![(a, vec![10])], + delete_files: vec![b], + delete_trackpoint_chunks: vec![t], + ..Default::default() + })) + .unwrap(); + assert_eq!(storage.counts(), (1, 0, 1)); + let data = block_on(storage.load()).unwrap(); + assert_eq!(data.files, vec![(a, vec![10])]); + assert_eq!( + (data.order, data.categories), + (Some(vec![5]), Some(vec![6])) + ); + + block_on(storage.commit(&Batch { + delete_waypoint_chunks: vec![w], + order: Some(vec![]), + ..Default::default() + })) + .unwrap(); + assert_eq!(storage.counts(), (1, 0, 0)); + assert_eq!(block_on(storage.load()).unwrap().order, Some(vec![])); + } + + #[test] + fn test_settings() { + let storage = MemoryStorage::default(); + block_on(storage.put_setting("a", "1")).unwrap(); + block_on(storage.put_setting("b", "2")).unwrap(); + block_on(storage.put_setting("a", "3")).unwrap(); + let mut settings = block_on(storage.load_settings()).unwrap(); + settings.sort(); + assert_eq!( + settings, + vec![("a".into(), "3".into()), ("b".into(), "2".into())] + ); + + block_on(storage.delete_setting("a")).unwrap(); + // deleting what is not there is fine + block_on(storage.delete_setting("missing")).unwrap(); + assert_eq!( + block_on(storage.load_settings()).unwrap(), + vec![("b".into(), "2".into())] + ); + } + + #[test] + fn test_settings_are_not_files() { + let storage = MemoryStorage::default(); + block_on(storage.put_setting("a", "1")).unwrap(); + assert_eq!(block_on(storage.load()).unwrap(), StoredData::default()); + } +} diff --git a/gpx-rs/engine/src/storage/mod.rs b/gpx-rs/engine/src/storage/mod.rs index e2344e95d..102156a59 100644 --- a/gpx-rs/engine/src/storage/mod.rs +++ b/gpx-rs/engine/src/storage/mod.rs @@ -83,3 +83,47 @@ pub trait Storage { async fn delete_setting(&self, key: &str) -> Result<(), StorageError>; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_batch_is_empty_until_it_holds_something() { + assert!(Batch::default().is_empty()); + let holds = [ + Batch { + put_files: vec![(FileId::default(), vec![])], + ..Default::default() + }, + Batch { + delete_files: vec![FileId::default()], + ..Default::default() + }, + Batch { + delete_trackpoint_chunks: vec![Uuid::new_v4()], + ..Default::default() + }, + Batch { + delete_waypoint_chunks: vec![Uuid::new_v4()], + ..Default::default() + }, + Batch { + order: Some(vec![]), + ..Default::default() + }, + Batch { + categories: Some(vec![]), + ..Default::default() + }, + ]; + assert!(holds.iter().all(|batch| !batch.is_empty())); + } + + #[test] + fn test_storage_error_is_readable() { + let error = StorageError("disk full".into()); + assert_eq!(error.to_string(), "storage error: disk full"); + let _: &dyn std::error::Error = &error; + } +}