This commit is contained in:
vcoppe committed 2026-10-14 09:22:07 +02:00
1 parent 41b9116c6a
commit 97d597e139
181 files changed
+1970 -2643

No files matched your search

@@ -0,0 +1,154 @@
use std::collections::{HashMap, HashSet};
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
/// of its file) changes, so editing a segment does not touch the buffers of the others.
#[derive(Debug, Default)]
pub struct CoordinatesCache {
segments: HashMap<TrackSegmentId, (TrackSegmentRevisionId, Vec<f64>)>,
waypoints: HashMap<FileId, (FileWaypointsRevisionId, Vec<f64>)>,
}
impl CoordinatesCache {
pub fn update(&mut self, files: Option<&Files>) {
let mut segments = HashSet::new();
let mut waypoints = HashSet::new();
for file in files.into_iter().flat_map(|files| files.values()) {
waypoints.insert(file.id);
if self
.waypoints
.get(&file.id)
.is_none_or(|(r, _)| *r != file.wpt.rev_id)
{
let mut coordinates = Vec::new();
for wpt in file.wpt.iter() {
coordinates.extend([wpt.coordinates.lng, wpt.coordinates.lat]);
}
self.waypoints
.insert(file.id, (file.wpt.rev_id, coordinates));
}
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) {
segments.insert(seg.id);
if self
.segments
.get(&seg.id)
.is_none_or(|(r, _)| *r != seg.rev_id)
{
let mut coordinates = Vec::with_capacity(seg.len() * 2);
for trkpt in seg.iter() {
coordinates.extend([trkpt.coordinates.lng, trkpt.coordinates.lat]);
}
self.segments.insert(seg.id, (seg.rev_id, coordinates));
}
}
}
self.segments.retain(|id, _| segments.contains(id));
self.waypoints.retain(|id, _| waypoints.contains(id));
}
/// Coordinates of the trackpoints of a segment, empty if it does not exist.
pub fn segment(&self, id: &TrackSegmentId) -> &[f64] {
self.segments.get(id).map_or(&[], |(_, c)| c)
}
/// Coordinates of the waypoints of a file, empty if it does not exist.
pub fn waypoints(&self, id: &FileId) -> &[f64] {
self.waypoints.get(id).map_or(&[], |(_, c)| c)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, LngLat, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
(fx, id)
}
#[test]
fn test_segment_buffers_match_trackpoints() {
let (fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
for seg in fx.files[&id].trk.iter().flat_map(|t| &t.trkseg) {
let buffer = cache.segment(&seg.id);
assert_eq!(buffer.len(), seg.len() * 2);
for (i, p) in seg.iter().enumerate() {
assert_eq!(buffer[2 * i], p.coordinates.lng);
assert_eq!(buffer[2 * i + 1], p.coordinates.lat);
}
}
assert!(cache.segment(&TrackSegmentId::default()).is_empty());
}
#[test]
fn test_only_changed_buffers_are_rebuilt_and_stale_ones_dropped() {
let (mut fx, id) = loaded();
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
let ids: Vec<_> = fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.id)
.collect();
assert!(ids.len() >= 2);
let ptr = |c: &CoordinatesCache, i: usize| c.segment(&ids[i]).as_ptr();
let (p0, p1) = (ptr(&cache, 0), ptr(&cache, 1));
let mut file = (*fx.files[&id]).clone();
let seg = &mut file.trk[0].trkseg[0];
seg.splice(0, 0, vec![Default::default()]);
seg.rev_id = Default::default();
let len = seg.len();
fx.files.insert(id, Rc::new(file));
cache.update(Some(&fx.files));
assert_eq!(cache.segment(&ids[0]).len(), len * 2);
assert_ne!(ptr(&cache, 0), p0);
assert_eq!(ptr(&cache, 1), p1);
cache.update(None);
assert!(cache.segment(&ids[0]).is_empty());
assert!(cache.waypoints(&id).is_empty());
}
#[test]
fn test_waypoint_buffer() {
let (mut fx, id) = loaded();
let wpt = |lng, lat| Waypoint {
coordinates: LngLat { lng, lat },
..Default::default()
};
let mut file = (*fx.files[&id]).clone();
file.wpt = crate::Waypoints::new([
WaypointChunk {
wpt: vec![wpt(1.0, 2.0)],
..Default::default()
},
WaypointChunk {
wpt: vec![wpt(3.0, 4.0)],
..Default::default()
},
]);
fx.files.insert(id, Rc::new(file));
let mut cache = CoordinatesCache::default();
cache.update(Some(&fx.files));
assert_eq!(cache.waypoints(&id), [1.0, 2.0, 3.0, 4.0]);
}
}
+155
View File
@@ -0,0 +1,155 @@
use crate::{
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId,
WaypointId,
};
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints,
/// with the ids that reference every element. Coordinates are not part of it, they are read
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
/// a segment).
#[derive(Debug, Clone, PartialEq)]
pub struct FileStructure {
pub id: FileId,
pub name: String,
pub desc: Option<String>,
pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change.
pub wpt_rev_id: FileWaypointsRevisionId,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub name: Option<String>,
pub desc: Option<String>,
/// Style of the track, only when the file defines it.
pub color: Option<String>,
pub opacity: Option<f64>,
pub width: Option<f64>,
pub segments: Vec<SegmentNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SegmentNode {
pub id: TrackSegmentId,
/// Changes whenever the trackpoints of the segment (hence their buffer) change.
pub rev_id: TrackSegmentRevisionId,
pub len: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WaypointNode {
pub id: WaypointId,
pub name: Option<String>,
pub sym: Option<String>,
}
impl FileStructure {
pub fn new(file: &File) -> Self {
Self {
id: file.id,
name: file.info.name.clone(),
desc: file.info.desc.clone(),
tracks: file
.trk
.iter()
.map(|trk| TrackNode {
id: trk.id,
name: trk.info.name.clone(),
desc: trk.info.desc.clone(),
color: trk.info.color.clone(),
opacity: trk.info.opacity,
width: trk.info.width,
segments: trk
.trkseg
.iter()
.map(|seg| SegmentNode {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
})
.collect(),
})
.collect(),
waypoints: file
.wpt
.iter()
.map(|wpt| WaypointNode {
id: wpt.id,
name: wpt.name.clone(),
sym: wpt.sym.clone(),
})
.collect(),
wpt_rev_id: file.wpt.rev_id,
}
}
}
#[cfg(test)]
mod tests {
use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_structure_of_a_file() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&fx.order.0[0]];
let node = FileStructure::new(file);
assert_eq!(
(node.id, &node.name, &node.desc),
(file.id, &file.info.name, &file.info.desc)
);
assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc));
assert_eq!(
(&n.color, n.opacity, n.width),
(&t.info.color, t.info.opacity, t.info.width)
);
assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
}
}
}
#[test]
fn test_waypoints_in_buffer_order_and_revision() {
let mut file = crate::File::default();
let wpt = |n: &str| Waypoint {
name: Some(n.to_string()),
..Default::default()
};
let before = file.wpt.rev_id;
file.wpt = crate::Waypoints::new([
WaypointChunk {
wpt: vec![wpt("a"), wpt("b")],
..Default::default()
},
WaypointChunk {
wpt: vec![wpt("c")],
..Default::default()
},
]);
let node = FileStructure::new(&file);
let names: Vec<_> = node
.waypoints
.iter()
.map(|w| w.name.clone().unwrap())
.collect();
assert_eq!(names, ["a", "b", "c"]);
assert_ne!(node.wpt_rev_id, before);
assert_eq!(node.wpt_rev_id, file.wpt.rev_id);
}
}
@@ -0,0 +1,92 @@
use std::collections::HashMap;
use crate::{Diff, FileId, FileStructure, Files};
/// The structure of each file, only recomputed for the files that changed according to the
/// [`Diff`] of the stack.
#[derive(Debug, Default)]
pub struct FileStructureCache {
map: HashMap<FileId, FileStructure>,
}
impl FileStructureCache {
/// Brings the cache in line with `files`, given what changed since the last update.
pub fn update(&mut self, files: Option<&Files>, diff: Option<&Diff>) {
if let Some(diff) = diff {
for id in diff.removed.iter() {
self.map.remove(id);
}
for id in diff.added.iter().chain(&diff.modified) {
if let Some(file) = files.and_then(|files| files.get(id)) {
self.map.insert(*id, FileStructure::new(file));
}
}
}
}
pub fn get(&self, id: &FileId) -> Option<&FileStructure> {
self.map.get(id)
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{Apply, Load, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_only_files_of_the_diff_are_recomputed() {
let mut fx = Fixture::default();
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let mut cache = FileStructureCache::default();
cache.update(
Some(&fx.files),
Some(&Diff {
added: vec![a, b],
..Default::default()
}),
);
let before = cache.get(&b).unwrap() as *const FileStructure;
let mut file = (*fx.files[&a]).clone();
file.info.name = "renamed".into();
fx.files.insert(a, Rc::new(file));
cache.update(
Some(&fx.files),
Some(&Diff {
modified: vec![a],
..Default::default()
}),
);
assert_eq!(cache.get(&a).unwrap().name, "renamed");
assert!(std::ptr::eq(cache.get(&b).unwrap(), before));
fx.files.remove(&b);
cache.update(
Some(&fx.files),
Some(&Diff {
removed: vec![b],
..Default::default()
}),
);
assert!(cache.get(&b).is_none());
assert!(cache.get(&a).is_some());
}
}
+13
View File
@@ -0,0 +1,13 @@
mod coordinates_cache;
mod file_structure;
mod file_structure_cache;
mod routing_buffer;
mod statistics_buffer;
mod statistics_cache;
pub use coordinates_cache::*;
pub use file_structure::*;
pub use file_structure_cache::*;
pub use routing_buffer::*;
pub use statistics_buffer::*;
pub use statistics_cache::*;
+162
View File
@@ -0,0 +1,162 @@
use crate::{FileId, Files, Selection, TrackSegmentId, TrackSegmentRevisionId};
/// What the routing tool needs about the selected segments (see
/// [`Selection::segment_locations`]): where their anchors are among their trackpoints.
///
/// Trackpoints are numbered like in the [`crate::StatisticsBuffer`], over all the selected
/// segments one after the other, except that the segments that are not covered by the routing
/// tool (waypoints) are left out.
#[derive(Debug, Default)]
pub struct RoutingBuffer {
/// The index of each anchor in the selection, in order. The first and last trackpoints of a
/// segment always are anchors.
pub anchor_indices: Vec<u32>,
/// The lowest map zoom level at which each anchor is shown.
pub anchor_zooms: Vec<u8>,
/// Index in the selection of the first trackpoint of each selected segment that has some,
/// which tells which segment an anchor or a trackpoint belongs to.
pub segment_starts: Vec<u32>,
/// Id of each selected segment that has trackpoints, as `segment_starts`.
pub segment_ids: Vec<TrackSegmentId>,
/// Changes when the selected segments, or their trackpoints, change. Commands that refer to
/// trackpoints by their index in the selection carry it, to be refused if the selection is
/// not the one the indices were taken from anymore.
pub revision: u32,
/// What the revision stands for.
signature: Vec<(TrackSegmentId, TrackSegmentRevisionId)>,
}
impl RoutingBuffer {
pub fn update(&mut self, files: Option<&Files>, selection: &Selection, order: &[FileId]) {
self.anchor_indices.clear();
self.anchor_zooms.clear();
self.segment_starts.clear();
self.segment_ids.clear();
let mut signature = vec![];
let mut index = 0;
for location in files
.map(|files| selection.segment_locations(files, order))
.unwrap_or_default()
{
let segment = &files.unwrap()[&location.file_id].trk[location.trk].trkseg[location.seg];
signature.push((segment.id, segment.rev_id));
if segment.is_empty() {
continue;
}
self.segment_starts.push(index as u32);
self.segment_ids.push(segment.id);
for trkpt in segment.iter() {
if let Some(zoom) = trkpt.anchor {
self.anchor_indices.push(index as u32);
self.anchor_zooms.push(zoom);
}
index += 1;
}
}
if signature != self.signature {
self.revision = self.revision.wrapping_add(1);
self.signature = signature;
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashSet, rc::Rc};
use crate::parse;
use super::*;
fn loaded() -> (Files, FileId) {
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let id = file.id;
let mut files = Files::default();
files.insert(id, Rc::new(file));
(files, id)
}
fn file_selection(id: FileId) -> Selection {
Selection::File {
file_ids: HashSet::from([id]),
}
}
#[test]
fn test_anchors_and_starts_follow_the_selected_segments() {
let (files, id) = loaded();
let lengths: Vec<usize> = files[&id]
.trk
.iter()
.flat_map(|trk| trk.trkseg.iter().map(|segment| segment.len()))
.collect();
let mut buffer = RoutingBuffer::default();
buffer.update(Some(&files), &file_selection(id), &[id]);
// an empty segment has no start
assert_eq!(buffer.segment_ids.len(), buffer.segment_starts.len());
let mut start = 0;
let expected: Vec<u32> = lengths
.iter()
.filter(|len| **len > 0)
.map(|len| {
start += len;
(start - len) as u32
})
.collect();
assert_eq!(buffer.segment_starts, expected);
// the ends of the segments are always anchors, shown at every zoom level
assert_eq!(buffer.anchor_indices.len(), buffer.anchor_zooms.len());
assert!(buffer.anchor_indices.windows(2).all(|w| w[0] < w[1]));
let total: usize = lengths.iter().sum();
let ends = expected
.iter()
.copied()
.chain(expected.iter().skip(1).map(|start| start - 1))
.chain([total as u32 - 1]);
for end in ends {
let position = buffer.anchor_indices.iter().position(|i| *i == end);
assert_eq!(position.map(|p| buffer.anchor_zooms[p]), Some(0), "{end}");
}
}
#[test]
fn test_nothing_without_segments() {
let (files, id) = loaded();
let mut buffer = RoutingBuffer::default();
buffer.update(Some(&files), &Selection::Empty, &[id]);
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
// waypoints are not covered by the routing tool
buffer.update(Some(&files), &Selection::Waypoints { file_id: id }, &[id]);
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
buffer.update(None, &file_selection(id), &[id]);
assert!(buffer.anchor_indices.is_empty() && buffer.segment_starts.is_empty());
}
#[test]
fn test_revision_changes_with_the_selected_segments() {
let (mut files, id) = loaded();
let mut buffer = RoutingBuffer::default();
buffer.update(Some(&files), &file_selection(id), &[id]);
let first = buffer.revision;
// the same segments: the indices still mean the same trackpoints
buffer.update(Some(&files), &file_selection(id), &[id]);
assert_eq!(buffer.revision, first);
// other trackpoints in the same segment
let mut file = (*files[&id]).clone();
file.trk[0].trkseg[0].rev_id = Default::default();
files.insert(id, Rc::new(file));
buffer.update(Some(&files), &file_selection(id), &[id]);
assert_ne!(buffer.revision, first);
let second = buffer.revision;
// another selection
buffer.update(Some(&files), &Selection::Empty, &[id]);
assert_ne!(buffer.revision, second);
}
}
@@ -0,0 +1,693 @@
use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options};
/// Value of the timestamps of the trackpoints that have none.
pub const NO_TIME: i64 = i64::MIN;
/// Flattened statistics of the selected segments, one entry per trackpoint, in selection order.
///
/// The distance, time, moving and elevation values are cumulative over the whole selection (the
/// second segment continues where the first one ended), so the statistics of any range of
/// trackpoints are the difference between its two ends: see [`StatisticsBuffer::slice`].
/// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are
/// [`NO_TIME`], missing measures are NaN.
///
/// The optional values (`moving_distance`, `total_time`, `moving_time`, `speed`, `time`, `hr`,
/// `cad`, `atemp`, `power`) are `None` when no trackpoint of the selection has one, and the OSM
/// attributes are stored as [`Intervals`]. The cumulative ones can only be missing before the
/// first trackpoint that has them, where they are 0.
#[derive(Debug, Default)]
pub struct StatisticsBuffer {
/// Statistics of the whole selection.
pub global: GlobalStatistics,
pub total_distance: Vec<f64>,
pub moving_distance: Option<Vec<f64>>,
pub total_time: Option<Vec<i64>>,
pub moving_time: Option<Vec<i64>>,
pub speed: Option<Vec<f64>>,
pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>,
pub slope_segment_slope: Vec<f64>,
pub slope_segment_distance: Vec<f64>,
pub lng: Vec<f64>,
pub lat: Vec<f64>,
pub ele: Vec<f64>,
/// Timestamps, in milliseconds since the epoch.
pub time: Option<Vec<i64>>,
pub hr: Option<Vec<f64>>,
pub cad: Option<Vec<f64>>,
pub atemp: Option<Vec<f64>>,
pub power: Option<Vec<f64>>,
/// Surface of the trackpoints, see [`Intervals`].
pub surface: Intervals,
/// Highway of the trackpoints, as the surface.
pub highway: Intervals,
/// SAC hiking scale of the trackpoints, as the surface.
pub sac_scale: Intervals,
/// Mountain biking scale of the trackpoints, as the surface.
pub mtb_scale: Intervals,
}
/// A value per trackpoint that rarely changes from one trackpoint to the next, stored as the
/// intervals of trackpoints that share it.
///
/// The values are 0 when unknown, else 1 + the code in the categories of the engine (so that it
/// is never 0 for a known value). `starts[i]` is the index of the first trackpoint of the
/// interval `i`, which ends where the next one starts, or at the last trackpoint for the last one.
/// Both are empty when there are no trackpoints.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Intervals {
pub starts: Vec<u32>,
pub values: Vec<u8>,
}
impl Intervals {
fn clear(&mut self) {
self.starts.clear();
self.values.clear();
}
/// Adds the value of the trackpoint at `index`, the indices being pushed in order.
fn push(&mut self, index: usize, value: u8) {
if self.values.last() != Some(&value) {
self.starts.push(index as u32);
self.values.push(value);
}
}
}
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
/// fits.
fn unknown_or_next(code: Option<u8>) -> u8 {
code.and_then(|code| code.checked_add(1)).unwrap_or(0)
}
/// Adds the value of the trackpoint at `index` to an optional column, the indices being pushed in
/// order. The column is created at the first present value, the previous trackpoints getting
/// `missing`.
fn push_optional<T: Copy>(column: &mut Option<Vec<T>>, index: usize, value: Option<T>, missing: T) {
match (column.as_mut(), value) {
(Some(column), value) => column.push(value.unwrap_or(missing)),
(None, Some(value)) => {
let mut new_column = vec![missing; index];
new_column.push(value);
*column = Some(new_column);
}
(None, None) => {}
}
}
impl StatisticsBuffer {
pub fn len(&self) -> usize {
self.total_distance.len()
}
pub fn is_empty(&self) -> bool {
self.total_distance.is_empty()
}
/// `selected`: the segments and their statistics, in order.
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
self.global = GlobalStatistics::default();
self.total_distance.clear();
self.moving_distance = None;
self.total_time = None;
self.moving_time = None;
self.speed = None;
self.elevation_gain.clear();
self.elevation_loss.clear();
self.slope.clear();
self.slope_segment_slope.clear();
self.slope_segment_distance.clear();
self.lng.clear();
self.lat.clear();
self.ele.clear();
self.time = None;
self.hr = None;
self.cad = None;
self.atemp = None;
self.power = None;
self.surface.clear();
self.highway.clear();
self.sac_scale.clear();
self.mtb_scale.clear();
for (segment, stats) in selected {
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
let index = self.total_distance.len();
let cumul_stats = &self.global;
self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance);
push_optional(
&mut self.moving_distance,
index,
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance),
0.0,
);
push_optional(
&mut self.total_time,
index,
sum_options(cumul_stats.total_time, trkpt_stats.total_time),
0,
);
push_optional(
&mut self.moving_time,
index,
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time),
0,
);
push_optional(&mut self.speed, index, trkpt_stats.speed, f64::NAN);
self.elevation_gain
.push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain);
self.elevation_loss
.push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss);
self.slope.push(trkpt_stats.slope);
self.slope_segment_slope
.push(trkpt_stats.slope_segment.slope);
self.slope_segment_distance
.push(trkpt_stats.slope_segment.distance);
self.lng.push(trkpt.coordinates.lng);
self.lat.push(trkpt.coordinates.lat);
self.ele.push(trkpt.ele);
push_optional(&mut self.time, index, trkpt.time, NO_TIME);
push_optional(&mut self.hr, index, trkpt.hr.map(f64::from), f64::NAN);
push_optional(&mut self.cad, index, trkpt.cad.map(f64::from), f64::NAN);
push_optional(&mut self.atemp, index, trkpt.atemp.map(f64::from), f64::NAN);
push_optional(&mut self.power, index, trkpt.power.map(f64::from), f64::NAN);
self.surface.push(index, unknown_or_next(trkpt.surface));
self.highway.push(index, unknown_or_next(trkpt.highway));
self.sac_scale.push(index, unknown_or_next(trkpt.sac_scale));
self.mtb_scale.push(index, unknown_or_next(trkpt.mtb_scale));
}
self.global.merge(&stats.global);
}
}
/// Statistics of the trackpoints from `start` to `end` (both included) of the selection, for
/// example the part of an elevation profile the user dragged over. `None` if the range is
/// not inside the selection.
///
/// It is done in constant time, from the cumulative values at both ends: the bounds and
/// the averages are the ones of the whole selection, they are not computed for the range.
pub fn slice(&self, start: usize, end: usize) -> Option<GlobalStatistics> {
if start > end || end >= self.len() {
return None;
}
let delta = |values: &[f64]| values[end] - values[start];
// the total time is the span of the timestamps, which is negative where they go backwards
let delta_time = |values: &[i64]| (values[end] - values[start]).max(0);
let time = |i: usize| {
let time = self.time.as_ref()?[i];
(time != NO_TIME).then_some(time)
};
Some(GlobalStatistics {
total_distance: delta(&self.total_distance),
moving_distance: self.moving_distance.as_deref().map(delta),
total_time: self.total_time.as_deref().map(delta_time),
moving_time: self.moving_time.as_deref().map(delta_time),
elevation_gain: delta(&self.elevation_gain),
elevation_loss: delta(&self.elevation_loss),
start_time: time(start),
end_time: time(end),
bounds: self.global.bounds,
})
}
}
#[cfg(test)]
mod tests {
use crate::parse;
use super::*;
fn computed(path: &str) -> (TrackSegment, Statistics) {
let data = std::fs::read(path).unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let stats = Statistics::compute(&segment);
(segment, stats)
}
/// The value of the trackpoint at `index`, 0 before the first interval.
fn value_at(intervals: &Intervals, index: usize) -> u8 {
match intervals
.starts
.partition_point(|start| *start as usize <= index)
{
0 => 0,
next => intervals.values[next - 1],
}
}
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
vec![
buffer.total_distance.len(),
buffer.elevation_gain.len(),
buffer.elevation_loss.len(),
buffer.slope.len(),
buffer.slope_segment_slope.len(),
buffer.slope_segment_distance.len(),
buffer.lng.len(),
buffer.lat.len(),
buffer.ele.len(),
]
}
#[test]
fn test_empty() {
let mut buffer = StatisticsBuffer::default();
buffer.update(&[]);
assert!(buffer.is_empty());
assert!(buffer.speed.is_none());
assert_eq!(buffer.global.total_distance, 0.0);
assert!(buffer.slice(0, 0).is_none());
}
#[test]
fn test_single_segment_matches_local_stats() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = s.local.len();
assert!(all_lengths(&buffer).iter().all(|len| *len == n));
assert_eq!(buffer.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);
}
// the trackpoints
for (i, trkpt) in segment.iter().enumerate() {
assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
assert_eq!(buffer.lat[i], trkpt.coordinates.lat);
assert_eq!(buffer.ele[i], trkpt.ele);
}
}
#[test]
fn test_global_is_the_merge_of_the_segments() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let global = &buffer.global;
assert!((global.total_distance - 2.0 * s.global.total_distance).abs() < 1e-9);
assert!((global.elevation_gain - 2.0 * s.global.elevation_gain).abs() < 1e-9);
assert!((global.elevation_loss - 2.0 * s.global.elevation_loss).abs() < 1e-9);
// and it is the last cumulative value of the points
assert_eq!(
global.total_distance,
*buffer.total_distance.last().unwrap()
);
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.global.total_distance, s.global.total_distance);
buffer.update(&[]);
assert_eq!(buffer.global.total_distance, 0.0);
}
#[test]
fn test_distances_accumulate_over_segments() {
let (segment, s) = computed("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &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 (segment, s) = computed("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(&[(&segment, &s), (&segment, &s)]);
let total_time = buffer.total_time.as_ref().unwrap();
assert_eq!(total_time[n - 1], duration);
assert_eq!(total_time[n], duration);
assert_eq!(total_time[2 * n - 1], 2 * duration);
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
assert!(buffer.time.unwrap().iter().all(|t| *t != NO_TIME));
}
#[test]
fn test_absent_optional_values_have_no_buffer() {
let (segment, s) = computed("data/simple.gpx");
assert!(segment.iter().all(|trkpt| trkpt.time.is_none()));
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert!(buffer.time.is_none());
assert!(buffer.hr.is_none());
assert!(buffer.cad.is_none());
assert!(buffer.atemp.is_none());
assert!(buffer.power.is_none());
assert_eq!(buffer.global.total_time, None);
assert_eq!(buffer.len(), s.local.len());
// they need times
assert!(buffer.speed.is_none());
assert!(buffer.moving_distance.is_none());
assert!(buffer.total_time.is_none());
assert!(buffer.moving_time.is_none());
// the slices do not need them
let slice = buffer.slice(0, 2).unwrap();
assert_eq!((slice.start_time, slice.end_time), (None, None));
}
#[test]
fn test_present_optional_values_have_one_entry_per_trackpoint() {
let (segment, s) = computed("data/with_time.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.time.as_ref().unwrap().len(), buffer.len());
assert!(buffer.hr.is_none());
let data = std::fs::read("data/with_hr.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let s = Statistics::compute(&segment);
buffer.update(&[(&segment, &s)]);
let hr = buffer.hr.as_ref().unwrap();
assert_eq!(hr.len(), buffer.len());
assert!(hr.iter().any(|v| !v.is_nan()));
}
#[test]
fn test_a_value_missing_for_some_trackpoints_is_a_sentinel() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
let mut point = segment[1].clone();
point.time = None;
point.hr = Some(120);
segment.splice(1, 2, vec![point]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let time = buffer.time.as_ref().unwrap();
assert_eq!(time.len(), n);
assert_eq!(time[1], NO_TIME);
assert_ne!(time[0], NO_TIME);
// the first trackpoint has no heart rate: it is filled once the first one is found
let hr = buffer.hr.as_ref().unwrap();
assert_eq!(hr.len(), n);
assert_eq!(hr[1], 120.0);
assert!(hr[0].is_nan() && hr[2].is_nan());
assert!(buffer.cad.is_none());
}
#[test]
fn test_cumulative_values_are_zero_before_the_first_one() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
let mut first = segment[0].clone();
first.time = None;
segment.splice(0, 1, vec![first]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let total_time = buffer.total_time.as_ref().unwrap();
assert_eq!(total_time.len(), n);
assert_eq!(total_time[0], 0);
assert_eq!(total_time[n - 1], s.global.total_time.unwrap());
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
// so a slice that starts on the first trackpoint is right
assert_eq!(
buffer.slice(0, n - 1).unwrap().total_time,
s.global.total_time
);
}
#[test]
fn test_surface_and_highway_intervals() {
let (segment, s) = computed("data/with_highway.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
// 0 for the trackpoints that have none, else the code of the engine plus one
let surface = &buffer.surface;
assert_eq!(surface.starts, [0, 2, 3]);
assert_eq!(surface.values, [1, 0, 2]);
let highway = &buffer.highway;
assert_eq!(
(&highway.starts[..], &highway.values[..]),
(&[0, 2, 3, 4][..], &[1, 0, 2, 0][..])
);
let sac_scale = &buffer.sac_scale;
assert_eq!(
(&sac_scale.starts[..], &sac_scale.values[..]),
(&[0, 3, 4][..], &[0, 1, 2][..])
);
let mtb_scale = &buffer.mtb_scale;
assert_eq!(
(&mtb_scale.starts[..], &mtb_scale.values[..]),
(&[0, 3][..], &[0, 1][..])
);
assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
// and they can be looked up
let values: Vec<u8> = (0..6).map(|i| value_at(&buffer.surface, i)).collect();
assert_eq!(values, [1, 1, 0, 2, 2, 2]);
}
#[test]
fn test_intervals_merge_equal_neighbours_across_segments() {
let (mut segment, _) = computed("data/simple.gpx");
let points: Vec<_> = segment
.iter()
.map(|trkpt| {
let mut trkpt = trkpt.clone();
trkpt.surface = Some(4);
trkpt
})
.collect();
segment.splice(0, points.len(), points);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
// one interval for the two segments
assert_eq!(buffer.surface.starts, [0]);
assert_eq!(buffer.surface.values, [5]);
// and a single one of unknown values when nothing is known
assert_eq!(buffer.highway.values, [0]);
}
#[test]
fn test_codes_of_the_trackpoints_are_used_as_they_are() {
let (mut segment, _) = computed("data/simple.gpx");
let mut point = segment[0].clone();
point.surface = Some(7);
point.highway = Some(254);
point.sac_scale = Some(0);
point.mtb_scale = Some(3);
segment.splice(0, 1, vec![point]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let at = |i: usize| {
(
value_at(&buffer.surface, i),
value_at(&buffer.highway, i),
value_at(&buffer.sac_scale, i),
value_at(&buffer.mtb_scale, i),
)
};
assert_eq!(at(0), (8, 255, 1, 4));
assert_eq!(at(1), (0, 0, 0, 0));
}
#[test]
fn test_unknown_intervals_without_surface_and_highway() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
// a single interval of unknown values
assert_eq!(buffer.surface.starts, [0]);
assert_eq!(buffer.surface.values, [0]);
assert_eq!(buffer.highway.starts, [0]);
assert_eq!(buffer.highway.values, [0]);
assert_eq!(buffer.sac_scale.values, [0]);
assert_eq!(buffer.mtb_scale.values, [0]);
// and they are reset by the next update
buffer.update(&[]);
assert_eq!(buffer.surface, Intervals::default());
assert_eq!(buffer.highway, Intervals::default());
assert_eq!(buffer.sac_scale, Intervals::default());
assert_eq!(buffer.mtb_scale, Intervals::default());
assert_eq!(value_at(&buffer.surface, 0), 0);
}
#[test]
fn test_update_replaces_previous_content() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
buffer.update(&[(&segment, &s)]);
assert_eq!(buffer.total_distance.len(), s.local.len());
assert!(all_lengths(&buffer).iter().all(|len| *len == s.local.len()));
buffer.update(&[]);
assert!(buffer.total_distance.is_empty());
assert!(all_lengths(&buffer).iter().all(|len| *len == 0));
}
#[test]
fn test_slice_of_the_whole_selection_is_the_global_statistics() {
let (segment, s) = computed("data/with_time.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
let slice = buffer.slice(0, buffer.len() - 1).unwrap();
let global = &buffer.global;
assert!((slice.total_distance - global.total_distance).abs() < 1e-9);
assert!((slice.elevation_gain - global.elevation_gain).abs() < 1e-9);
assert!((slice.elevation_loss - global.elevation_loss).abs() < 1e-9);
assert_eq!(slice.total_time, global.total_time);
assert_eq!(slice.moving_time, global.moving_time);
assert_eq!(slice.start_time, global.start_time);
assert_eq!(slice.end_time, global.end_time);
}
#[test]
fn test_slices_of_corrupt_timestamps_are_never_negative_nor_infinite() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
assert!(n > 40);
// timestamps that go back, then forward, and some that are missing
let times: Vec<_> = segment.iter().map(|p| p.time.unwrap()).collect();
segment.update_all(|i, pt| {
pt.time = match i {
10..=19 => Some(times[29 - i]),
20..=24 => None,
25..=29 => Some(times[0] - 1_000 * i as i64),
_ => pt.time,
};
});
let stats = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &stats)]);
for start in 0..n {
for end in start..n {
let slice = buffer.slice(start, end).unwrap();
for time in [slice.total_time, slice.moving_time].into_iter().flatten() {
assert!(time >= 0, "time of {start}..={end}");
}
for speed in [slice.total_speed(), slice.moving_speed()]
.into_iter()
.flatten()
{
assert!(
speed.is_finite() && speed >= 0.0,
"speed of {start}..={end}"
);
}
}
}
}
#[test]
fn test_slice_of_a_range() {
let (segment, s) = computed("data/with_time.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let (start, end) = (n / 4, n / 2);
let slice = buffer.slice(start, end).unwrap();
assert!(
(slice.total_distance - (buffer.total_distance[end] - buffer.total_distance[start]))
.abs()
< 1e-12
);
assert_eq!(
slice.total_time,
Some(
buffer.total_time.as_ref().unwrap()[end]
- buffer.total_time.as_ref().unwrap()[start]
)
);
assert_eq!(
slice.moving_time,
Some(
buffer.moving_time.as_ref().unwrap()[end]
- buffer.moving_time.as_ref().unwrap()[start]
)
);
assert_eq!(slice.start_time, Some(buffer.time.as_ref().unwrap()[start]));
assert_eq!(slice.end_time, Some(buffer.time.as_ref().unwrap()[end]));
assert!(slice.total_distance < buffer.global.total_distance);
assert!(slice.total_speed().is_some());
// a single point has nothing between its ends
let point = buffer.slice(start, start).unwrap();
assert_eq!(point.total_distance, 0.0);
assert_eq!(point.total_time, Some(0));
}
#[test]
fn test_slice_keeps_the_bounds_of_the_selection() {
let data = std::fs::read("data/with_hr.gpx").unwrap();
let file = parse(&data, &mut Default::default()).unwrap();
let segment = file.trk[0].trkseg[0].clone();
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let slice = buffer.slice(1, 3).unwrap();
let (global, bounds) = (&buffer.global, &slice.bounds);
assert_eq!(
(bounds.sw.lng, bounds.sw.lat),
(global.bounds.sw.lng, global.bounds.sw.lat)
);
assert_eq!(
(bounds.ne.lng, bounds.ne.lat),
(global.bounds.ne.lng, global.bounds.ne.lat)
);
}
#[test]
fn test_durations_are_not_limited_to_24_days() {
// 30 days of recording, in milliseconds
let (segment, mut s) = computed("data/with_time.gpx");
let month = 30 * 24 * 3_600_000_i64;
s.global.total_time = Some(month);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.global.total_time, Some(2 * month));
assert!(2 * month > i64::from(i32::MAX));
}
#[test]
fn test_slice_across_segments() {
let (segment, s) = computed("data/simple.gpx");
let n = s.local.len();
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]);
// from the middle of the first segment to the middle of the second one
let slice = buffer.slice(n / 2, n + n / 2).unwrap();
let expected = buffer.total_distance[n + n / 2] - buffer.total_distance[n / 2];
assert!((slice.total_distance - expected).abs() < 1e-9);
assert_eq!(slice.total_time, None);
}
#[test]
fn test_slice_out_of_range() {
let (segment, s) = computed("data/simple.gpx");
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let n = buffer.len();
assert!(buffer.slice(0, n).is_none());
assert!(buffer.slice(n, n).is_none());
assert!(buffer.slice(5, 4).is_none());
assert!(buffer.slice(0, n - 1).is_some());
}
}
@@ -0,0 +1,299 @@
use std::collections::{HashMap, HashSet, hash_map::Entry};
use crate::{
File, FileId, Files, GlobalStatistics, Selection, Statistics, Track, TrackSegment,
TrackSegmentRevisionId,
};
/// A segment and its statistics.
pub type SelectedSegment<'a> = (&'a TrackSegment, &'a Statistics);
#[derive(Debug, Default)]
pub struct StatisticsCache {
map: HashMap<TrackSegmentRevisionId, Statistics>,
}
impl StatisticsCache {
pub fn update(&mut self, state: Option<&Files>) {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state {
for file in state.values() {
for trk in file.trk.iter() {
for trkseg in trk.trkseg.iter() {
match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id);
}
Entry::Vacant(e) => {
e.insert(Statistics::compute(trkseg));
}
}
}
}
}
}
for id in stale.iter() {
self.map.remove(id);
}
}
/// Statistics of the segments of the selection.
pub fn get<'a>(
&'a self,
state: Option<&'a Files>,
selection: &Selection,
) -> Vec<&'a Statistics> {
self.selected(state, selection, &[])
.into_iter()
.map(|(_, stats)| stats)
.collect()
}
/// The segments of the selection with their statistics. The segments of several selected
/// files follow `order` (the files missing from it come last).
pub fn selected<'a>(
&'a self,
state: Option<&'a Files>,
selection: &Selection,
order: &[FileId],
) -> Vec<SelectedSegment<'a>> {
let mut selected = vec![];
if let Some(state) = state {
match selection {
Selection::Empty => (),
Selection::File { file_ids } => {
let ordered = order
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
if let Some(file) = state.get(id) {
self.add_file_stats(file, &mut selected);
}
}
}
Selection::Track { file_id, trk_ids } => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if trk_ids.contains(&trk.id) {
self.add_track_stats(trk, &mut selected);
}
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() {
if *trk_id == trk.id {
for trkseg in trk.trkseg.iter() {
if trkseg_ids.contains(&trkseg.id) {
self.add_segment_stats(trkseg, &mut selected);
}
}
break;
}
}
}
}
Selection::Waypoints { file_id } => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut selected);
}
}
Selection::Waypoint {
file_id,
wpt_ids: _,
} => {
if let Some(file) = state.get(file_id) {
self.add_file_stats(file, &mut selected);
}
}
}
}
selected
}
/// Global statistics of a file: its segments merged, in order.
pub fn file_global(&self, file: &File) -> GlobalStatistics {
let mut selected = vec![];
self.add_file_stats(file, &mut selected);
let mut global = GlobalStatistics::default();
for (_, stats) in selected {
global.merge(&stats.global);
}
global
}
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
for trk in file.trk.iter() {
self.add_track_stats(trk, selected);
}
}
fn add_track_stats<'a>(&'a self, trk: &'a Track, selected: &mut Vec<SelectedSegment<'a>>) {
for trkseg in trk.trkseg.iter() {
self.add_segment_stats(trkseg, selected);
}
}
fn add_segment_stats<'a>(
&'a self,
trkseg: &'a TrackSegment,
selected: &mut Vec<SelectedSegment<'a>>,
) {
if let Some(stats) = self.map.get(&trkseg.rev_id) {
selected.push((trkseg, stats));
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashSet, rc::Rc};
use crate::{FileId, parse};
use super::*;
fn state(path: &str) -> (Files, Rc<File>) {
let data = std::fs::read(path).unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
let mut state = Files::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 = Files::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 = Files::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());
}
#[test]
fn test_segments_of_several_files_follow_the_given_order() {
let (mut state, first) = state("data/simple.gpx");
let (other, second) = {
let data = std::fs::read("data/with_time.gpx").unwrap();
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
(file.clone(), file)
};
state.insert(other.id, other);
let mut cache = StatisticsCache::default();
cache.update(Some(&state));
let selection = Selection::File {
file_ids: HashSet::from([first.id, second.id]),
};
let ids = |order: &[FileId]| -> Vec<_> {
cache
.selected(Some(&state), &selection, order)
.iter()
.map(|(segment, _)| segment.id)
.collect()
};
let (a, b) = (first.trk[0].trkseg[0].id, second.trk[0].trkseg[0].id);
assert_eq!(ids(&[first.id, second.id]), vec![a, b]);
assert_eq!(ids(&[second.id, first.id]), vec![b, a]);
// the files that are not in the order come last
assert_eq!(ids(&[second.id]), vec![b, a]);
}
}