mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 02:27:18 +00:00
progress
This commit is contained in:
1 parent
41b9116c6a
commit
97d597e139
181 files changed
+1970
-2643
No files matched your search
@@ -0,0 +1,683 @@
|
||||
//! The binary form of what is stored.
|
||||
//!
|
||||
//! Every blob starts with a version byte, then the data (postcard, which does not name the fields:
|
||||
//! the form is exactly the types that wrote it). So a form never changes once it has been
|
||||
//! written: the types of each version live in their own module (see [`v1`]) and are frozen, and
|
||||
//! a change is a new version next to them. Blobs of any known version can be read; they are
|
||||
//! written again in the latest version when they are written again. Chunks are never rewritten
|
||||
//! unless they change, so blobs of several versions live together in a storage.
|
||||
//!
|
||||
//! To change the form: add `vN`, make `LATEST` its number, convert from it in the decoders, and
|
||||
//! keep the golden bytes of the older versions in the tests.
|
||||
|
||||
mod v1;
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId,
|
||||
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
|
||||
WaypointChunkId, Waypoints,
|
||||
};
|
||||
|
||||
/// The version that is written.
|
||||
pub const LATEST: u8 = 1;
|
||||
|
||||
/// Why a blob could not be read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecodeError {
|
||||
/// It was written by a version that this one does not know: a newer one, or not a blob.
|
||||
UnknownVersion(u8),
|
||||
/// It has a known version, but not what it should have.
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
fn encode<T: Serialize + ?Sized>(value: &T) -> Vec<u8> {
|
||||
postcard::to_extend(value, vec![LATEST]).expect("serializing to memory")
|
||||
}
|
||||
|
||||
/// The version of a blob, and its data.
|
||||
fn split(bytes: &[u8]) -> Result<(u8, &[u8]), DecodeError> {
|
||||
bytes
|
||||
.split_first()
|
||||
.map(|(v, data)| (*v, data))
|
||||
.ok_or(DecodeError::Corrupt)
|
||||
}
|
||||
|
||||
fn parse<'a, T: Deserialize<'a>>(data: &'a [u8]) -> Result<T, DecodeError> {
|
||||
postcard::from_bytes(data).map_err(|_| DecodeError::Corrupt)
|
||||
}
|
||||
|
||||
/// A file without its trackpoints and waypoints, which are in chunks.
|
||||
pub struct FileRecord {
|
||||
info: FileInfo,
|
||||
tracks: Vec<TrackRecord>,
|
||||
waypoints: Vec<WaypointChunkId>,
|
||||
}
|
||||
|
||||
struct TrackRecord {
|
||||
id: TrackId,
|
||||
info: TrackInfo,
|
||||
segments: Vec<SegmentRecord>,
|
||||
}
|
||||
|
||||
struct SegmentRecord {
|
||||
id: TrackSegmentId,
|
||||
chunks: Vec<TrackpointChunkId>,
|
||||
}
|
||||
|
||||
impl From<v1::FileRecord1> for FileRecord {
|
||||
fn from(record: v1::FileRecord1) -> Self {
|
||||
Self {
|
||||
info: record.info.into(),
|
||||
tracks: record
|
||||
.tracks
|
||||
.into_iter()
|
||||
.map(|track| TrackRecord {
|
||||
id: TrackId(track.id),
|
||||
info: track.info.into(),
|
||||
segments: track
|
||||
.segments
|
||||
.into_iter()
|
||||
.map(|segment| SegmentRecord {
|
||||
id: TrackSegmentId(segment.id),
|
||||
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileRecord {
|
||||
/// The file made of the record and its chunks, `None` if one is missing. Chunks that several
|
||||
/// files use stay shared.
|
||||
pub fn build(
|
||||
self,
|
||||
id: FileId,
|
||||
trackpoint_chunks: &impl Fn(&TrackpointChunkId) -> Option<Rc<TrackpointChunk>>,
|
||||
waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option<Rc<WaypointChunk>>,
|
||||
) -> Option<File> {
|
||||
let mut file = File {
|
||||
id,
|
||||
info: self.info,
|
||||
..Default::default()
|
||||
};
|
||||
for track in self.tracks {
|
||||
let mut trkseg = Vec::with_capacity(track.segments.len());
|
||||
for segment in track.segments {
|
||||
let mut built = TrackSegment::default();
|
||||
built.id = segment.id;
|
||||
for chunk in &segment.chunks {
|
||||
built.push_shared(trackpoint_chunks(chunk)?);
|
||||
}
|
||||
trkseg.push(built);
|
||||
}
|
||||
file.trk.push(Track {
|
||||
id: track.id,
|
||||
info: track.info,
|
||||
trkseg,
|
||||
});
|
||||
}
|
||||
let mut wpt = Waypoints::default();
|
||||
for chunk in &self.waypoints {
|
||||
wpt.push_shared(waypoint_chunks(chunk)?);
|
||||
}
|
||||
file.wpt = wpt;
|
||||
Some(file)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_file(file: &File) -> Vec<u8> {
|
||||
encode(&v1::FileRecord1::new(file))
|
||||
}
|
||||
|
||||
pub fn decode_file(bytes: &[u8]) -> Result<FileRecord, DecodeError> {
|
||||
match split(bytes)? {
|
||||
(1, data) => Ok(parse::<v1::FileRecord1>(data)?.into()),
|
||||
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of a chunk of trackpoints. Its surfaces, highways and scales are written as names,
|
||||
/// the codes of `categories` being the ones of the engine that has the chunk.
|
||||
pub fn encode_trackpoints(chunk: &TrackpointChunk, categories: &TrackpointCategories) -> Vec<u8> {
|
||||
encode(&v1::TrackpointChunk1::new(&chunk.trkpt, categories))
|
||||
}
|
||||
|
||||
/// A chunk of trackpoints from its bytes. The names it holds are added to `categories` if it does
|
||||
/// not know them yet, and the trackpoints refer to them by the codes of `categories`.
|
||||
pub fn decode_trackpoints(
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
categories: &mut TrackpointCategories,
|
||||
) -> Result<TrackpointChunk, DecodeError> {
|
||||
let trkpt = match split(bytes)? {
|
||||
(1, data) => parse::<v1::TrackpointChunk1>(data)?.into_points(categories),
|
||||
(version, _) => return Err(DecodeError::UnknownVersion(version)),
|
||||
};
|
||||
Ok(TrackpointChunk {
|
||||
id: TrackpointChunkId(id),
|
||||
trkpt,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec<u8> {
|
||||
encode(
|
||||
&chunk
|
||||
.wpt
|
||||
.iter()
|
||||
.map(v1::Waypoint1::from)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Result<WaypointChunk, DecodeError> {
|
||||
let wpt = match split(bytes)? {
|
||||
(1, data) => parse::<Vec<v1::Waypoint1>>(data)?
|
||||
.into_iter()
|
||||
.map(Waypoint::from)
|
||||
.collect(),
|
||||
(version, _) => return Err(DecodeError::UnknownVersion(version)),
|
||||
};
|
||||
Ok(WaypointChunk {
|
||||
id: WaypointChunkId(id),
|
||||
wpt,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_order(order: &[FileId]) -> Vec<u8> {
|
||||
encode(&order.iter().map(|id| id.0).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
pub fn decode_order(bytes: &[u8]) -> Result<Vec<FileId>, DecodeError> {
|
||||
match split(bytes)? {
|
||||
(1, data) => Ok(parse::<Vec<Uuid>>(data)?.into_iter().map(FileId).collect()),
|
||||
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Author, Chunk, Link, LngLat, Trackpoint};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn id(n: u128) -> Uuid {
|
||||
Uuid::from_u128(n)
|
||||
}
|
||||
|
||||
fn link(href: &str) -> Link {
|
||||
Link {
|
||||
href: href.into(),
|
||||
text: Some(format!("text of {href}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn trackpoint(n: u8) -> Trackpoint {
|
||||
let f = f64::from(n);
|
||||
Trackpoint {
|
||||
coordinates: LngLat {
|
||||
lng: 4.0 + f,
|
||||
lat: 50.0 + f,
|
||||
},
|
||||
ele: 100.5 + f,
|
||||
time: Some(1_700_000_000_000 + i64::from(n)),
|
||||
atemp: Some(-3),
|
||||
hr: Some(140 + u16::from(n)),
|
||||
cad: Some(80),
|
||||
power: Some(250),
|
||||
// the first has the second name of the table, which comes first in the chunk
|
||||
surface: Some(if n == 0 { 1 } else { 0 }),
|
||||
highway: Some(0),
|
||||
sac_scale: Some(0),
|
||||
mtb_scale: Some(0),
|
||||
anchor: Some(n),
|
||||
}
|
||||
}
|
||||
|
||||
fn waypoint() -> Waypoint {
|
||||
Waypoint {
|
||||
id: crate::WaypointId(id(7)),
|
||||
coordinates: LngLat {
|
||||
lng: 5.5,
|
||||
lat: 51.5,
|
||||
},
|
||||
ele: 321.0,
|
||||
time: Some(1_700_000_000_000),
|
||||
name: Some("summit".into()),
|
||||
cmt: Some("comment".into()),
|
||||
desc: Some("description".into()),
|
||||
links: vec![
|
||||
link("https://example.com/summit"),
|
||||
link("https://example.com/summit-2"),
|
||||
],
|
||||
sym: Some("Summit".into()),
|
||||
type_: Some("peak".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file that has a value for every field, with fixed ids.
|
||||
fn sample() -> File {
|
||||
let mut file = File {
|
||||
id: FileId(id(1)),
|
||||
info: FileInfo {
|
||||
name: "sample".into(),
|
||||
desc: Some("about".into()),
|
||||
author: Some(Author {
|
||||
name: Some("someone".into()),
|
||||
email: Some("someone@example.com".into()),
|
||||
link: Some(link("https://example.com/someone")),
|
||||
}),
|
||||
links: vec![
|
||||
link("https://example.com/file"),
|
||||
link("https://example.com/file-2"),
|
||||
],
|
||||
time: Some(1_700_000_000_000),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut segment = TrackSegment::default();
|
||||
segment.id = TrackSegmentId(id(3));
|
||||
segment.push(TrackpointChunk {
|
||||
id: TrackpointChunkId(id(4)),
|
||||
trkpt: vec![trackpoint(0), trackpoint(1), Trackpoint::default()],
|
||||
});
|
||||
let mut empty = TrackSegment::default();
|
||||
empty.id = TrackSegmentId(id(6));
|
||||
file.trk.push(Track {
|
||||
id: TrackId(id(2)),
|
||||
info: TrackInfo {
|
||||
name: Some("track".into()),
|
||||
cmt: Some("cmt".into()),
|
||||
desc: Some("desc".into()),
|
||||
src: Some("src".into()),
|
||||
links: vec![
|
||||
link("https://example.com/track"),
|
||||
link("https://example.com/track-2"),
|
||||
],
|
||||
type_: Some("Cycling".into()),
|
||||
color: Some("ff0000".into()),
|
||||
opacity: Some(0.5),
|
||||
width: Some(6.0),
|
||||
},
|
||||
trkseg: vec![segment, empty],
|
||||
});
|
||||
file.wpt.push(WaypointChunk {
|
||||
id: WaypointChunkId(id(5)),
|
||||
wpt: vec![
|
||||
waypoint(),
|
||||
Waypoint {
|
||||
id: crate::WaypointId(id(8)),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
});
|
||||
file
|
||||
}
|
||||
|
||||
fn categories() -> TrackpointCategories {
|
||||
let mut categories = TrackpointCategories::default();
|
||||
categories.surface.code("asphalt");
|
||||
categories.surface.code("gravel");
|
||||
categories.highway.code("path");
|
||||
categories.sac_scale.code("hiking");
|
||||
categories.mtb_scale.code("mtb0");
|
||||
categories
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn unhex(text: &str) -> Vec<u8> {
|
||||
(0..text.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The chunks of a file, by identity.
|
||||
fn chunks_of(
|
||||
file: &File,
|
||||
) -> (
|
||||
HashMap<TrackpointChunkId, Rc<TrackpointChunk>>,
|
||||
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
fn rebuilt(file: &File, record: FileRecord) -> Option<File> {
|
||||
let (trackpoints, waypoints) = chunks_of(file);
|
||||
record.build(file.id, &|id| trackpoints.get(id).cloned(), &|id| {
|
||||
waypoints.get(id).cloned()
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_same_file(a: &File, b: &File) {
|
||||
assert_eq!(a.id, b.id);
|
||||
assert_eq!(a.info, b.info);
|
||||
assert_eq!(a.trk.len(), b.trk.len());
|
||||
for (a, b) in a.trk.iter().zip(&b.trk) {
|
||||
assert_eq!((a.id, &a.info), (b.id, &b.info));
|
||||
assert_eq!(a.trkseg.len(), b.trkseg.len());
|
||||
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
|
||||
assert_eq!(a.id, b.id);
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.chunks().iter().zip(b.chunks()) {
|
||||
assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(a.wpt.len(), b.wpt.len());
|
||||
for (a, b) in a.wpt.chunks().iter().zip(b.wpt.chunks()) {
|
||||
assert!(Rc::ptr_eq(a, b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_record_rebuilds_the_file_with_the_same_chunks() {
|
||||
let file = sample();
|
||||
let record = decode_file(&encode_file(&file)).unwrap();
|
||||
assert_same_file(&rebuilt(&file, record).unwrap(), &file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_file_record_of_a_missing_chunk_builds_nothing() {
|
||||
let file = sample();
|
||||
let (trackpoints, waypoints) = chunks_of(&file);
|
||||
let record = || decode_file(&encode_file(&file)).unwrap();
|
||||
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_a_file_without_data_builds_an_empty_file() {
|
||||
let file = File::default();
|
||||
let built = decode_file(&encode_file(&file))
|
||||
.unwrap()
|
||||
.build(file.id, &|_| None, &|_| None)
|
||||
.unwrap();
|
||||
assert!(built.trk.is_empty() && built.wpt.is_empty());
|
||||
}
|
||||
|
||||
/// The names of the categories of a trackpoint.
|
||||
fn names(point: &Trackpoint, categories: &TrackpointCategories) -> [Option<String>; 4] {
|
||||
let name = |table: &crate::Categories, code: Option<u8>| {
|
||||
code.and_then(|code| table.name(code)).map(str::to_owned)
|
||||
};
|
||||
[
|
||||
name(&categories.surface, point.surface),
|
||||
name(&categories.highway, point.highway),
|
||||
name(&categories.sac_scale, point.sac_scale),
|
||||
name(&categories.mtb_scale, point.mtb_scale),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_keep_their_identity_and_items() {
|
||||
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||
let categories = categories();
|
||||
let mut reader = categories.clone();
|
||||
let decoded = decode_trackpoints(
|
||||
chunk.id.0,
|
||||
&encode_trackpoints(chunk, &categories),
|
||||
&mut reader,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.id, chunk.id);
|
||||
assert_eq!(decoded.trkpt.len(), 3);
|
||||
// read by an engine that has the same table, the trackpoints are the same
|
||||
assert_eq!(reader, categories);
|
||||
for (a, b) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
||||
assert_eq!(format!("{a:?}"), format!("{b:?}"));
|
||||
}
|
||||
|
||||
let chunk = &sample().wpt.chunks()[0].clone();
|
||||
let decoded = decode_waypoints(chunk.id.0, &encode_waypoints(chunk)).unwrap();
|
||||
assert_eq!(decoded.id, chunk.id);
|
||||
for (a, b) in decoded.wpt.iter().zip(&chunk.wpt) {
|
||||
assert_eq!(a.id, b.id);
|
||||
assert!(a.same_content(b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_chunk_means_the_same_to_an_engine_with_another_table() {
|
||||
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||
let writer = categories();
|
||||
let bytes = encode_trackpoints(chunk, &writer);
|
||||
|
||||
// an engine that learned other names first, and the same ones in another order
|
||||
let mut reader = TrackpointCategories::default();
|
||||
reader.surface.code("sand");
|
||||
reader.surface.code("gravel");
|
||||
reader.surface.code("asphalt");
|
||||
reader.highway.code("track");
|
||||
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
|
||||
|
||||
for (read, written) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
||||
assert_eq!(names(read, &reader), names(written, &writer));
|
||||
}
|
||||
// the codes are the ones of the reader, which learned what it did not know
|
||||
assert_eq!(decoded.trkpt[0].surface, Some(1));
|
||||
assert_eq!(decoded.trkpt[1].surface, Some(2));
|
||||
assert_eq!(reader.highway.names(), ["track", "path"]);
|
||||
assert_eq!(decoded.trkpt[0].highway, Some(1));
|
||||
assert_eq!(reader.sac_scale.names(), ["hiking"]);
|
||||
// and an empty engine learns them in the order they come
|
||||
let mut empty = TrackpointCategories::default();
|
||||
decode_trackpoints(chunk.id.0, &bytes, &mut empty).unwrap();
|
||||
assert_eq!(empty.surface.names(), ["gravel", "asphalt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_chunk_only_carries_the_names_it_uses() {
|
||||
let mut writer = TrackpointCategories::default();
|
||||
for i in 0..10 {
|
||||
writer.surface.code(&format!("surface-{i}"));
|
||||
}
|
||||
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
|
||||
chunk.iter_mut().for_each(|p| p.surface = Some(7));
|
||||
let chunk = TrackpointChunk::new(chunk);
|
||||
let bytes = encode_trackpoints(&chunk, &writer);
|
||||
|
||||
let mut reader = TrackpointCategories::default();
|
||||
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
|
||||
assert_eq!(reader.surface.names(), ["surface-7"]);
|
||||
assert!(decoded.trkpt.iter().all(|p| p.surface == Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codes_without_a_name_are_not_stored_and_names_without_room_are_lost() {
|
||||
let mut writer = categories();
|
||||
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
|
||||
// a code that the table does not know
|
||||
chunk[1].surface = Some(200);
|
||||
let chunk = TrackpointChunk::new(chunk);
|
||||
let bytes = encode_trackpoints(&chunk, &writer);
|
||||
let decoded =
|
||||
decode_trackpoints(chunk.id.0, &bytes, &mut TrackpointCategories::default()).unwrap();
|
||||
assert_eq!(decoded.trkpt[1].surface, None);
|
||||
assert_eq!(decoded.trkpt[0].surface, Some(0));
|
||||
|
||||
// an engine whose table is full keeps the names it has and loses the other ones
|
||||
let mut full = TrackpointCategories::default();
|
||||
for i in 0..crate::Categories::CAPACITY {
|
||||
full.surface.code(&format!("other-{i}"));
|
||||
}
|
||||
writer.surface.code("other-3");
|
||||
let mut chunk = chunk.trkpt.clone();
|
||||
chunk[0].surface = writer.surface.code("other-3");
|
||||
chunk[1].surface = writer.surface.code("gravel");
|
||||
let chunk = TrackpointChunk::new(chunk);
|
||||
let decoded =
|
||||
decode_trackpoints(chunk.id.0, &encode_trackpoints(&chunk, &writer), &mut full)
|
||||
.unwrap();
|
||||
assert_eq!(decoded.trkpt[0].surface, Some(3));
|
||||
assert_eq!(decoded.trkpt[1].surface, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_read_one_after_the_other_share_the_table() {
|
||||
let writer = categories();
|
||||
let chunk = sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||
let bytes = encode_trackpoints(&chunk, &writer);
|
||||
let mut reader = TrackpointCategories::default();
|
||||
let a = decode_trackpoints(id(1), &bytes, &mut reader).unwrap();
|
||||
let b = decode_trackpoints(id(2), &bytes, &mut reader).unwrap();
|
||||
for (a, b) in a.trkpt.iter().zip(&b.trkpt) {
|
||||
assert_eq!((a.surface, a.highway), (b.surface, b.highway));
|
||||
}
|
||||
assert_eq!(reader.surface.names().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_order_round_trips() {
|
||||
let order = vec![FileId(id(9)), FileId(id(8))];
|
||||
assert_eq!(decode_order(&encode_order(&order)).unwrap(), order);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blobs_say_their_version_and_unknown_ones_are_told_apart_from_corrupt_ones() {
|
||||
assert_eq!(encode_file(&sample())[0], LATEST);
|
||||
assert_eq!(encode_order(&[])[0], LATEST);
|
||||
|
||||
let mut bytes = encode_order(&[FileId(id(1))]);
|
||||
bytes[0] = LATEST + 1;
|
||||
assert_eq!(
|
||||
decode_order(&bytes),
|
||||
Err(DecodeError::UnknownVersion(LATEST + 1))
|
||||
);
|
||||
assert_eq!(decode_order(&[0]), Err(DecodeError::UnknownVersion(0)));
|
||||
assert!(matches!(
|
||||
decode_file(&bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_trackpoints(id(1), &bytes, &mut Default::default()),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode_waypoints(id(1), &bytes),
|
||||
Err(DecodeError::UnknownVersion(_))
|
||||
));
|
||||
|
||||
// nothing at all, or a known version with something else
|
||||
assert_eq!(decode_order(&[]), Err(DecodeError::Corrupt));
|
||||
assert_eq!(
|
||||
decode_file(&[LATEST, 0xff, 0xff]).err(),
|
||||
Some(DecodeError::Corrupt)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_trackpoints(id(1), &[LATEST, 9, 9, 9], &mut Default::default()).err(),
|
||||
Some(DecodeError::Corrupt)
|
||||
);
|
||||
}
|
||||
|
||||
// The stored forms of version 1, as they were written when the version was made. They must
|
||||
// stay readable, whatever happens to the engine types: if a test below fails, the form was
|
||||
// changed by mistake, or a version was not added when it should have been.
|
||||
const FILE_V1: &str = concat!(
|
||||
"010673616d706c65010561626f7574010107736f6d656f6e650113736f6d656f6e65406578616d706c652e63",
|
||||
"6f6d011b68747470733a2f2f6578616d706c652e636f6d2f736f6d656f6e65012374657874206f6620687474",
|
||||
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
|
||||
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
|
||||
"70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
|
||||
"6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472",
|
||||
"61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261",
|
||||
"636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a",
|
||||
"2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70",
|
||||
"6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000",
|
||||
"0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000",
|
||||
"000000000000000000000000000600011000000000000000000000000000000005",
|
||||
);
|
||||
const TRACKPOINTS_V1: &str = concat!(
|
||||
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
|
||||
"1040000000000000494000000000002059400180a0abfef9620105018c01015001fa01010001000100010001",
|
||||
"000000000000001440000000000080494000000000006059400182a0abfef9620105018d01015001fa010101",
|
||||
"010001000100010100000000000000000000000000000000000000000000000000000000000000000000",
|
||||
);
|
||||
const WAYPOINTS_V1: &str = concat!(
|
||||
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
|
||||
"80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e021a68747470733a",
|
||||
"2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c",
|
||||
"652e636f6d2f73756d6d69741c68747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32012474",
|
||||
"657874206f662068747470733a2f2f6578616d706c652e636f6d2f73756d6d69742d32010653756d6d697401",
|
||||
"047065616b100000000000000000000000000000000800000000000000000000000000000000000000000000",
|
||||
"000000000000000000",
|
||||
);
|
||||
const ORDER_V1: &str =
|
||||
"010210000000000000000000000000000000091000000000000000000000000000000008";
|
||||
|
||||
#[test]
|
||||
fn test_version_1_is_still_written_as_it_was() {
|
||||
let file = sample();
|
||||
assert_eq!(hex(&encode_file(&file)), FILE_V1);
|
||||
assert_eq!(
|
||||
hex(&encode_trackpoints(
|
||||
&file.trk[0].trkseg[0].chunks()[0],
|
||||
&categories()
|
||||
)),
|
||||
TRACKPOINTS_V1
|
||||
);
|
||||
assert_eq!(hex(&encode_waypoints(&file.wpt.chunks()[0])), WAYPOINTS_V1);
|
||||
assert_eq!(
|
||||
hex(&encode_order(&[FileId(id(9)), FileId(id(8))])),
|
||||
ORDER_V1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_1_is_still_read_as_it_was() {
|
||||
let file = sample();
|
||||
assert_same_file(
|
||||
&rebuilt(&file, decode_file(&unhex(FILE_V1)).unwrap()).unwrap(),
|
||||
&file,
|
||||
);
|
||||
let mut reader = TrackpointCategories::default();
|
||||
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1), &mut reader).unwrap();
|
||||
let expected = &file.trk[0].trkseg[0].chunks()[0];
|
||||
assert_eq!(chunk.trkpt.len(), expected.trkpt.len());
|
||||
for (read, written) in chunk.trkpt.iter().zip(&expected.trkpt) {
|
||||
assert_eq!(names(read, &reader), names(written, &categories()));
|
||||
assert_eq!(read.hr, written.hr);
|
||||
assert_eq!(read.anchor, written.anchor);
|
||||
}
|
||||
let chunk = decode_waypoints(id(5), &unhex(WAYPOINTS_V1)).unwrap();
|
||||
assert_eq!(chunk.wpt.len(), 2);
|
||||
assert!(chunk.wpt[0].same_content(&waypoint()));
|
||||
assert_eq!(chunk.wpt[0].id, waypoint().id);
|
||||
assert_eq!(
|
||||
decode_order(&unhex(ORDER_V1)).unwrap(),
|
||||
vec![FileId(id(9)), FileId(id(8))]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Version 1 of the stored form. It is frozen: the engine types may change, these may not (a test
|
||||
//! compares the bytes they give with the ones of a fixture). A change of the form is a new
|
||||
//! version, next to this one, that the decoders convert from.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, Trackpoint, TrackpointCategories,
|
||||
Waypoint, WaypointId,
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Link1 {
|
||||
href: String,
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Link> for Link1 {
|
||||
fn from(link: &Link) -> Self {
|
||||
Self {
|
||||
href: link.href.clone(),
|
||||
text: link.text.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Link1> for Link {
|
||||
fn from(link: Link1) -> Self {
|
||||
Self {
|
||||
href: link.href,
|
||||
text: link.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Author1 {
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
link: Option<Link1>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FileInfo1 {
|
||||
name: String,
|
||||
desc: Option<String>,
|
||||
author: Option<Author1>,
|
||||
links: Vec<Link1>,
|
||||
time: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<&FileInfo> for FileInfo1 {
|
||||
fn from(info: &FileInfo) -> Self {
|
||||
Self {
|
||||
name: info.name.clone(),
|
||||
desc: info.desc.clone(),
|
||||
author: info.author.as_ref().map(|author| Author1 {
|
||||
name: author.name.clone(),
|
||||
email: author.email.clone(),
|
||||
link: author.link.as_ref().map(Into::into),
|
||||
}),
|
||||
links: info.links.iter().map(Into::into).collect(),
|
||||
time: info.time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FileInfo1> for FileInfo {
|
||||
fn from(info: FileInfo1) -> Self {
|
||||
Self {
|
||||
name: info.name,
|
||||
desc: info.desc,
|
||||
author: info.author.map(|author| Author {
|
||||
name: author.name,
|
||||
email: author.email,
|
||||
link: author.link.map(Into::into),
|
||||
}),
|
||||
links: info.links.into_iter().map(Into::into).collect(),
|
||||
time: info.time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TrackInfo1 {
|
||||
name: Option<String>,
|
||||
cmt: Option<String>,
|
||||
desc: Option<String>,
|
||||
src: Option<String>,
|
||||
links: Vec<Link1>,
|
||||
type_: Option<String>,
|
||||
color: Option<String>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<&TrackInfo> for TrackInfo1 {
|
||||
fn from(info: &TrackInfo) -> Self {
|
||||
Self {
|
||||
name: info.name.clone(),
|
||||
cmt: info.cmt.clone(),
|
||||
desc: info.desc.clone(),
|
||||
src: info.src.clone(),
|
||||
links: info.links.iter().map(Into::into).collect(),
|
||||
type_: info.type_.clone(),
|
||||
color: info.color.clone(),
|
||||
opacity: info.opacity,
|
||||
width: info.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TrackInfo1> for TrackInfo {
|
||||
fn from(info: TrackInfo1) -> Self {
|
||||
Self {
|
||||
name: info.name,
|
||||
cmt: info.cmt,
|
||||
desc: info.desc,
|
||||
src: info.src,
|
||||
links: info.links.into_iter().map(Into::into).collect(),
|
||||
type_: info.type_,
|
||||
color: info.color,
|
||||
opacity: info.opacity,
|
||||
width: info.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file without its trackpoints and waypoints, which are in chunks.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FileRecord1 {
|
||||
pub info: FileInfo1,
|
||||
pub tracks: Vec<TrackRecord1>,
|
||||
pub waypoints: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TrackRecord1 {
|
||||
pub id: Uuid,
|
||||
pub info: TrackInfo1,
|
||||
pub segments: Vec<SegmentRecord1>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SegmentRecord1 {
|
||||
pub id: Uuid,
|
||||
pub chunks: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl FileRecord1 {
|
||||
pub fn new(file: &File) -> Self {
|
||||
Self {
|
||||
info: (&file.info).into(),
|
||||
tracks: file
|
||||
.trk
|
||||
.iter()
|
||||
.map(|track| TrackRecord1 {
|
||||
id: track.id.0,
|
||||
info: (&track.info).into(),
|
||||
segments: track
|
||||
.trkseg
|
||||
.iter()
|
||||
.map(|segment| SegmentRecord1 {
|
||||
id: segment.id.0,
|
||||
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Trackpoint1 {
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
time: Option<i64>,
|
||||
atemp: Option<i16>,
|
||||
hr: Option<u16>,
|
||||
cad: Option<u16>,
|
||||
power: Option<u16>,
|
||||
/// The position of the name in the list of the chunk (see [`TrackpointChunk1`]).
|
||||
surface: Option<u8>,
|
||||
highway: Option<u8>,
|
||||
sac_scale: Option<u8>,
|
||||
mtb_scale: Option<u8>,
|
||||
anchor: Option<u8>,
|
||||
}
|
||||
|
||||
impl From<&Trackpoint> for Trackpoint1 {
|
||||
fn from(point: &Trackpoint) -> Self {
|
||||
Self {
|
||||
lng: point.coordinates.lng,
|
||||
lat: point.coordinates.lat,
|
||||
ele: point.ele,
|
||||
time: point.time,
|
||||
atemp: point.atemp,
|
||||
hr: point.hr,
|
||||
cad: point.cad,
|
||||
power: point.power,
|
||||
surface: point.surface,
|
||||
highway: point.highway,
|
||||
sac_scale: point.sac_scale,
|
||||
mtb_scale: point.mtb_scale,
|
||||
anchor: point.anchor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Trackpoint1> for Trackpoint {
|
||||
fn from(point: Trackpoint1) -> Self {
|
||||
Self {
|
||||
coordinates: LngLat {
|
||||
lng: point.lng,
|
||||
lat: point.lat,
|
||||
},
|
||||
ele: point.ele,
|
||||
time: point.time,
|
||||
atemp: point.atemp,
|
||||
hr: point.hr,
|
||||
cad: point.cad,
|
||||
power: point.power,
|
||||
surface: point.surface,
|
||||
highway: point.highway,
|
||||
sac_scale: point.sac_scale,
|
||||
mtb_scale: point.mtb_scale,
|
||||
anchor: point.anchor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Waypoint1 {
|
||||
id: Uuid,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
time: Option<i64>,
|
||||
name: Option<String>,
|
||||
cmt: Option<String>,
|
||||
desc: Option<String>,
|
||||
links: Vec<Link1>,
|
||||
sym: Option<String>,
|
||||
type_: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Waypoint> for Waypoint1 {
|
||||
fn from(waypoint: &Waypoint) -> Self {
|
||||
Self {
|
||||
id: waypoint.id.0,
|
||||
lng: waypoint.coordinates.lng,
|
||||
lat: waypoint.coordinates.lat,
|
||||
ele: waypoint.ele,
|
||||
time: waypoint.time,
|
||||
name: waypoint.name.clone(),
|
||||
cmt: waypoint.cmt.clone(),
|
||||
desc: waypoint.desc.clone(),
|
||||
links: waypoint.links.iter().map(Into::into).collect(),
|
||||
sym: waypoint.sym.clone(),
|
||||
type_: waypoint.type_.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Waypoint1> for Waypoint {
|
||||
fn from(waypoint: Waypoint1) -> Self {
|
||||
Self {
|
||||
id: WaypointId(waypoint.id),
|
||||
coordinates: LngLat {
|
||||
lng: waypoint.lng,
|
||||
lat: waypoint.lat,
|
||||
},
|
||||
ele: waypoint.ele,
|
||||
time: waypoint.time,
|
||||
name: waypoint.name,
|
||||
cmt: waypoint.cmt,
|
||||
desc: waypoint.desc,
|
||||
links: waypoint.links.into_iter().map(Into::into).collect(),
|
||||
sym: waypoint.sym,
|
||||
type_: waypoint.type_,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk of trackpoints. It carries the names its categories refer to: the surface, highway,
|
||||
/// SAC scale and MTB scale of its trackpoints are the positions of the names in its own lists,
|
||||
/// which only hold the names it uses. So it means the same whatever the table of categories of
|
||||
/// the engine that reads it is, and it is not changed by what other chunks learn.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TrackpointChunk1 {
|
||||
surface: Vec<String>,
|
||||
highway: Vec<String>,
|
||||
sac_scale: Vec<String>,
|
||||
mtb_scale: Vec<String>,
|
||||
points: Vec<Trackpoint1>,
|
||||
}
|
||||
|
||||
/// The names of a category used by a chunk, with their position in the list of the chunk.
|
||||
struct LocalNames<'a> {
|
||||
table: &'a Categories,
|
||||
names: Vec<String>,
|
||||
local: HashMap<u8, u8>,
|
||||
}
|
||||
|
||||
impl<'a> LocalNames<'a> {
|
||||
fn new(table: &'a Categories) -> Self {
|
||||
Self {
|
||||
table,
|
||||
names: vec![],
|
||||
local: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The position in the list of the chunk of the name of a code of the table. A code that
|
||||
/// has no name has no position.
|
||||
fn position(&mut self, code: Option<u8>) -> Option<u8> {
|
||||
let code = code?;
|
||||
if let Some(position) = self.local.get(&code) {
|
||||
return Some(*position);
|
||||
}
|
||||
let name = self.table.name(code)?;
|
||||
let position = u8::try_from(self.names.len()).ok()?;
|
||||
self.names.push(name.to_owned());
|
||||
self.local.insert(code, position);
|
||||
Some(position)
|
||||
}
|
||||
}
|
||||
|
||||
/// The codes of the table for the names of a chunk, which are added to it if it does not know
|
||||
/// them. A name that does not fit in a full table has no code.
|
||||
fn codes_of(names: &[String], table: &mut Categories) -> Vec<Option<u8>> {
|
||||
names.iter().map(|name| table.code(name)).collect()
|
||||
}
|
||||
|
||||
impl TrackpointChunk1 {
|
||||
pub fn new(points: &[Trackpoint], categories: &TrackpointCategories) -> Self {
|
||||
let mut surface = LocalNames::new(&categories.surface);
|
||||
let mut highway = LocalNames::new(&categories.highway);
|
||||
let mut sac_scale = LocalNames::new(&categories.sac_scale);
|
||||
let mut mtb_scale = LocalNames::new(&categories.mtb_scale);
|
||||
let points = points
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let mut stored = Trackpoint1::from(point);
|
||||
stored.surface = surface.position(point.surface);
|
||||
stored.highway = highway.position(point.highway);
|
||||
stored.sac_scale = sac_scale.position(point.sac_scale);
|
||||
stored.mtb_scale = mtb_scale.position(point.mtb_scale);
|
||||
stored
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
surface: surface.names,
|
||||
highway: highway.names,
|
||||
sac_scale: sac_scale.names,
|
||||
mtb_scale: mtb_scale.names,
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
/// The trackpoints, with the codes of `categories` for the names of the chunk.
|
||||
pub fn into_points(self, categories: &mut TrackpointCategories) -> Vec<Trackpoint> {
|
||||
let surface = codes_of(&self.surface, &mut categories.surface);
|
||||
let highway = codes_of(&self.highway, &mut categories.highway);
|
||||
let sac_scale = codes_of(&self.sac_scale, &mut categories.sac_scale);
|
||||
let mtb_scale = codes_of(&self.mtb_scale, &mut categories.mtb_scale);
|
||||
let global = |codes: &[Option<u8>], local: Option<u8>| {
|
||||
local.and_then(|local| codes.get(usize::from(local)).copied().flatten())
|
||||
};
|
||||
self.points
|
||||
.into_iter()
|
||||
.map(|stored| {
|
||||
let (s, h, sac, mtb) = (
|
||||
global(&surface, stored.surface),
|
||||
global(&highway, stored.highway),
|
||||
global(&sac_scale, stored.sac_scale),
|
||||
global(&mtb_scale, stored.mtb_scale),
|
||||
);
|
||||
let mut point = Trackpoint::from(stored);
|
||||
point.surface = s;
|
||||
point.highway = h;
|
||||
point.sac_scale = sac;
|
||||
point.mtb_scale = mtb;
|
||||
point
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||
};
|
||||
|
||||
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
||||
/// and what the tests use.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MemoryStorage {
|
||||
state: RefCell<State>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
head: Revision,
|
||||
files: HashMap<FileId, Vec<u8>>,
|
||||
chunks: HashMap<ChunkKey, Vec<u8>>,
|
||||
order: Option<Vec<u8>>,
|
||||
settings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// How much there is in a storage.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StorageCounts {
|
||||
pub files: usize,
|
||||
pub trackpoint_chunks: usize,
|
||||
pub waypoint_chunks: usize,
|
||||
}
|
||||
|
||||
impl MemoryStorage {
|
||||
/// How many files and chunks are stored.
|
||||
pub fn counts(&self) -> StorageCounts {
|
||||
let state = self.state.borrow();
|
||||
let chunks = |kind| state.chunks.keys().filter(|key| key.kind == kind).count();
|
||||
StorageCounts {
|
||||
files: state.files.len(),
|
||||
trackpoint_chunks: chunks(ChunkKind::Trackpoints),
|
||||
waypoint_chunks: chunks(ChunkKind::Waypoints),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage for MemoryStorage {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
let state = self.state.borrow();
|
||||
Ok(StoredData {
|
||||
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
order: state.order.clone(),
|
||||
head: state.head,
|
||||
})
|
||||
}
|
||||
|
||||
async fn head(&self) -> Result<Revision, StorageError> {
|
||||
Ok(self.state.borrow().head)
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if state.head != batch.expect_head {
|
||||
return Err(CommitError::Stale { head: state.head });
|
||||
}
|
||||
state.files.extend(batch.put_files.iter().cloned());
|
||||
for id in &batch.delete_files {
|
||||
state.files.remove(id);
|
||||
}
|
||||
state.chunks.extend(batch.put_chunks.iter().cloned());
|
||||
for key in &batch.delete_chunks {
|
||||
state.chunks.remove(key);
|
||||
}
|
||||
if let Some(order) = &batch.order {
|
||||
state.order = Some(order.clone());
|
||||
}
|
||||
state.head = state.head.next();
|
||||
Ok(state.head)
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
let state = self.state.borrow();
|
||||
Ok(state
|
||||
.settings
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
self.state
|
||||
.borrow_mut()
|
||||
.settings
|
||||
.insert(key.to_owned(), value.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
self.state.borrow_mut().settings.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn block_on<F: Future>(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"),
|
||||
}
|
||||
}
|
||||
|
||||
fn counts(files: usize, trackpoint_chunks: usize, waypoint_chunks: usize) -> StorageCounts {
|
||||
StorageCounts {
|
||||
files,
|
||||
trackpoint_chunks,
|
||||
waypoint_chunks,
|
||||
}
|
||||
}
|
||||
|
||||
#[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(), 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_chunks: vec![
|
||||
(ChunkKey::trackpoints(t), vec![3]),
|
||||
(ChunkKey::waypoints(w), vec![4]),
|
||||
],
|
||||
order: Some(vec![5]),
|
||||
expect_head: Revision(0),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), 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])]);
|
||||
data.chunks.sort_by_key(|(_, bytes)| bytes.clone());
|
||||
assert_eq!(
|
||||
data.chunks,
|
||||
vec![
|
||||
(ChunkKey::trackpoints(t), vec![3]),
|
||||
(ChunkKey::waypoints(w), vec![4])
|
||||
]
|
||||
);
|
||||
assert_eq!(data.order, Some(vec![5]));
|
||||
|
||||
// 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_chunks: vec![ChunkKey::trackpoints(t)],
|
||||
expect_head: Revision(1),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), counts(1, 0, 1));
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data.files, vec![(a, vec![10])]);
|
||||
assert_eq!(data.order, Some(vec![5]));
|
||||
|
||||
block_on(storage.commit(&Batch {
|
||||
delete_chunks: vec![ChunkKey::waypoints(w)],
|
||||
order: Some(vec![]),
|
||||
expect_head: Revision(2),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), 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());
|
||||
}
|
||||
|
||||
fn batch(expect_head: u64, entry: u8) -> Batch {
|
||||
Batch {
|
||||
put_files: vec![(FileId::default(), vec![entry])],
|
||||
expect_head: Revision(expect_head),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_commit_advances_the_head() {
|
||||
let storage = MemoryStorage::default();
|
||||
assert_eq!(block_on(storage.head()).unwrap(), Revision(0));
|
||||
assert_eq!(block_on(storage.load()).unwrap().head, Revision(0));
|
||||
assert_eq!(block_on(storage.commit(&batch(0, 1))), Ok(Revision(1)));
|
||||
assert_eq!(block_on(storage.commit(&batch(1, 2))), Ok(Revision(2)));
|
||||
assert_eq!(block_on(storage.head()).unwrap(), Revision(2));
|
||||
assert_eq!(block_on(storage.load()).unwrap().head, Revision(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_commit_based_on_another_head_changes_nothing() {
|
||||
let storage = MemoryStorage::default();
|
||||
block_on(storage.commit(&batch(0, 1))).unwrap();
|
||||
block_on(storage.commit(&batch(1, 2))).unwrap();
|
||||
let before = block_on(storage.load()).unwrap();
|
||||
|
||||
// behind, or ahead: the head is not the one it is based on
|
||||
for expect in [0, 1, 3] {
|
||||
let stale = Batch {
|
||||
delete_files: vec![before.files[0].0],
|
||||
order: Some(vec![9]),
|
||||
..batch(expect, 3)
|
||||
};
|
||||
assert_eq!(
|
||||
block_on(storage.commit(&stale)),
|
||||
Err(CommitError::Stale { head: Revision(2) })
|
||||
);
|
||||
}
|
||||
let after = block_on(storage.load()).unwrap();
|
||||
assert_eq!(after.head, Revision(2));
|
||||
assert_eq!(after.files.len(), before.files.len());
|
||||
assert_eq!(after.order, before.order);
|
||||
// and it can be done again from the right head
|
||||
assert_eq!(block_on(storage.commit(&batch(2, 3))), Ok(Revision(3)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Keeping the files and the settings between sessions.
|
||||
//!
|
||||
//! The engine does not know where the data goes: the host (the browser, a desktop app...)
|
||||
//! implements [`Storage`], and a [`Persister`] works out what to write after each change.
|
||||
//!
|
||||
//! The files are not stored as a whole. Their trackpoints and waypoints are stored in chunks (see
|
||||
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
|
||||
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
|
||||
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
|
||||
//!
|
||||
//! Several engines can write to a storage (tabs, a server). It has a head, a revision that each
|
||||
//! commit advances, and a commit only applies if it is based on the current head: an engine that
|
||||
//! is behind is refused, and has to read the storage again, which replaces what it holds, before
|
||||
//! it writes. So what an engine knows of the storage is exact whenever it writes, which is what
|
||||
//! lets it write only what changed and delete what it does not use anymore.
|
||||
//!
|
||||
//! The surface, highway and scales of the trackpoints are stored as names, in the chunk that has
|
||||
//! them: the codes that the engine gives them (see [`crate::TrackpointCategories`]) depend on the
|
||||
//! order in which it learned the names, so they mean nothing outside of it. A storage that two
|
||||
//! engines (tabs, a server) write to therefore has nothing to agree on but the chunks.
|
||||
|
||||
mod codec;
|
||||
mod memory;
|
||||
mod persister;
|
||||
|
||||
pub use memory::*;
|
||||
pub use persister::*;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::FileId;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageError {
|
||||
/// The storage could not be read or written.
|
||||
Failed(String),
|
||||
/// Some of what is stored was written by a newer version of the app, which this one cannot
|
||||
/// read. Nothing is written then, so that the newer version finds its data again.
|
||||
Newer,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StorageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Failed(message) => write!(f, "storage error: {message}"),
|
||||
Self::Newer => write!(f, "stored by a newer version"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StorageError {}
|
||||
|
||||
/// A state of the storage: the head is the revision of the last commit, 0 for a storage that was
|
||||
/// never written to.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Revision(pub u64);
|
||||
|
||||
impl Revision {
|
||||
pub fn next(self) -> Self {
|
||||
Self(self.0 + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a commit did not happen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CommitError {
|
||||
/// The head is not the one the commit is based on: another engine wrote since.
|
||||
Stale {
|
||||
head: Revision,
|
||||
},
|
||||
Failed(StorageError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommitError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Stale { head } => write!(f, "the storage changed (now at revision {})", head.0),
|
||||
Self::Failed(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommitError {}
|
||||
|
||||
impl From<StorageError> for CommitError {
|
||||
fn from(error: StorageError) -> Self {
|
||||
Self::Failed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a chunk holds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChunkKind {
|
||||
Trackpoints,
|
||||
Waypoints,
|
||||
}
|
||||
|
||||
/// Where a chunk is stored: chunks are told apart by their identity within their kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ChunkKey {
|
||||
pub kind: ChunkKind,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl ChunkKey {
|
||||
pub fn trackpoints(id: Uuid) -> Self {
|
||||
Self {
|
||||
kind: ChunkKind::Trackpoints,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waypoints(id: Uuid) -> Self {
|
||||
Self {
|
||||
kind: ChunkKind::Waypoints,
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that is stored about the files, as opaque bytes (see the module documentation).
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct StoredData {
|
||||
/// The record of each file.
|
||||
pub files: Vec<(FileId, Vec<u8>)>,
|
||||
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
/// The order of the files.
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// The revision of the storage that this is the state of.
|
||||
pub head: Revision,
|
||||
}
|
||||
|
||||
/// A change of the stored data, to be applied all at once: a stored state is always one that the
|
||||
/// engine had.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Batch {
|
||||
pub put_files: Vec<(FileId, Vec<u8>)>,
|
||||
pub delete_files: Vec<FileId>,
|
||||
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
pub delete_chunks: Vec<ChunkKey>,
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// The head that the batch is based on: it is only applied if the head still is that one.
|
||||
pub expect_head: Revision,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self == &Self::default()
|
||||
}
|
||||
|
||||
/// How many chunks of the kind the batch writes.
|
||||
pub fn puts(&self, kind: ChunkKind) -> usize {
|
||||
self.put_chunks
|
||||
.iter()
|
||||
.filter(|(key, _)| key.kind == kind)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// How many chunks of the kind the batch deletes.
|
||||
pub fn deletes(&self, kind: ChunkKind) -> usize {
|
||||
self.delete_chunks
|
||||
.iter()
|
||||
.filter(|key| key.kind == kind)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that
|
||||
/// keeps nothing after the session.
|
||||
///
|
||||
/// The methods are async because storage often is (IndexedDB), the engine itself is not: it
|
||||
/// changes its files at once and the changes are written afterwards, see [`Persister`].
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait Storage {
|
||||
/// Reads the files.
|
||||
async fn load(&self) -> Result<StoredData, StorageError>;
|
||||
|
||||
/// The head of the storage.
|
||||
async fn head(&self) -> Result<Revision, StorageError>;
|
||||
|
||||
/// Applies a batch if the head is the one it is based on (`Stale` otherwise): either all of it
|
||||
/// is stored, or none. The head advances. Gives the new head.
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError>;
|
||||
|
||||
/// Reads the settings, as `(key, value)`. The engine does not look into them.
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError>;
|
||||
|
||||
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 {
|
||||
put_chunks: vec![(ChunkKey::waypoints(Uuid::new_v4()), vec![])],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
delete_chunks: vec![ChunkKey::trackpoints(Uuid::new_v4())],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
assert!(holds.iter().all(|batch| !batch.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_error_is_readable() {
|
||||
let error = StorageError::Failed("disk full".into());
|
||||
assert_eq!(error.to_string(), "storage error: disk full");
|
||||
let _: &dyn std::error::Error = &error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::codec::{
|
||||
DecodeError, decode_file, decode_order, decode_trackpoints, decode_waypoints, encode_file,
|
||||
encode_order, encode_trackpoints, encode_waypoints,
|
||||
};
|
||||
use crate::{
|
||||
Batch, ChunkKey, CommitError, File, FileId, Files, Revision, Storage, StorageError,
|
||||
TrackpointCategories, TrackpointChunk, WaypointChunk,
|
||||
};
|
||||
|
||||
/// What there is to keep of the engine: the files of the current state, their order, and the
|
||||
/// categories their trackpoints refer to. See [`crate::Engine::snapshot`].
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub files: Files,
|
||||
pub order: Vec<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
}
|
||||
|
||||
/// What was read from the storage, to start an engine with (see [`crate::Engine::restore`]).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Restored {
|
||||
pub files: Vec<Rc<File>>,
|
||||
pub order: Vec<FileId>,
|
||||
pub categories: TrackpointCategories,
|
||||
}
|
||||
|
||||
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
|
||||
/// already holds.
|
||||
///
|
||||
/// A file whose `Rc` is the saved one has not changed, which is what makes a save cheap: only the
|
||||
/// files that changed are looked at to find the chunks to write, and the others are only counted
|
||||
/// to find the chunks that nothing uses anymore.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Persister {
|
||||
/// The files as they are saved.
|
||||
files: HashMap<FileId, Rc<File>>,
|
||||
/// The files that have a record, including the ones that could not be read.
|
||||
stored_files: HashSet<FileId>,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
order: Option<Vec<FileId>>,
|
||||
/// The head of the storage that all of this is the state of.
|
||||
head: Revision,
|
||||
/// Set by `restore`, see [`Restored::read_only`].
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
/// Remembers whether something that was read comes from a newer version.
|
||||
#[derive(Default)]
|
||||
struct Newer(Cell<bool>);
|
||||
|
||||
impl Newer {
|
||||
fn read<T>(&self, result: Result<T, DecodeError>) -> Option<T> {
|
||||
match result {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) => {
|
||||
self.0
|
||||
.set(self.0.get() || matches!(error, DecodeError::UnknownVersion(_)));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a save writes, and what is stored once it is written.
|
||||
struct Plan {
|
||||
batch: Batch,
|
||||
snapshot: Snapshot,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
}
|
||||
|
||||
impl Persister {
|
||||
/// Reads the files of the storage. The persister then knows that they are saved.
|
||||
///
|
||||
/// If some of what is stored was written by a newer version, nothing is read (`Newer`), and the
|
||||
/// persister does not save until it reads a storage that is not newer: the newer version
|
||||
/// needs its data.
|
||||
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
|
||||
let data = storage.load().await?;
|
||||
*self = Self::default();
|
||||
|
||||
// what comes from a newer version is not corrupt: it must not be overwritten
|
||||
let newer = Newer::default();
|
||||
// the names of the chunks are added to the categories of the engine as they are read
|
||||
let categories: RefCell<TrackpointCategories> = Default::default();
|
||||
let stored_order: Option<Vec<FileId>> = data
|
||||
.order
|
||||
.as_deref()
|
||||
.and_then(|bytes| newer.read(decode_order(bytes)));
|
||||
|
||||
let chunk_bytes: HashMap<ChunkKey, Vec<u8>> = data.chunks.into_iter().collect();
|
||||
// chunks are decoded once, whatever the number of files that use them
|
||||
let trackpoint_cache: RefCell<HashMap<Uuid, Option<Rc<TrackpointChunk>>>> =
|
||||
Default::default();
|
||||
let waypoint_cache: RefCell<HashMap<Uuid, Option<Rc<WaypointChunk>>>> = Default::default();
|
||||
let trackpoint_chunk = |id: &crate::TrackpointChunkId| {
|
||||
trackpoint_cache
|
||||
.borrow_mut()
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
|
||||
newer
|
||||
.read(decode_trackpoints(
|
||||
id.0,
|
||||
bytes,
|
||||
&mut categories.borrow_mut(),
|
||||
))
|
||||
.map(Rc::new)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
let waypoint_chunk = |id: &crate::WaypointChunkId| {
|
||||
waypoint_cache
|
||||
.borrow_mut()
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = chunk_bytes.get(&ChunkKey::waypoints(id.0))?;
|
||||
newer.read(decode_waypoints(id.0, bytes)).map(Rc::new)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
||||
for (id, bytes) in &data.files {
|
||||
self.stored_files.insert(*id);
|
||||
let file = newer
|
||||
.read(decode_file(bytes))
|
||||
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
||||
// a file that cannot be read is left out, and dropped at the next save
|
||||
if let Some(file) = file {
|
||||
files.insert(*id, Rc::new(file));
|
||||
}
|
||||
}
|
||||
if newer.0.get() {
|
||||
// `*self` was reset: only what is needed to not write, and to know the storage
|
||||
self.read_only = true;
|
||||
self.head = data.head;
|
||||
return Err(StorageError::Newer);
|
||||
}
|
||||
|
||||
let mut order: Vec<FileId> = stored_order
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|id| files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
let ordered: HashSet<FileId> = order.iter().copied().collect();
|
||||
order.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
|
||||
let restored = Restored {
|
||||
files: order.iter().map(|id| files[id].clone()).collect(),
|
||||
order,
|
||||
categories: categories.into_inner(),
|
||||
};
|
||||
|
||||
self.files = files;
|
||||
self.chunks = chunk_bytes.into_keys().collect();
|
||||
self.order = stored_order;
|
||||
self.head = data.head;
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// The revision of the storage that the persister knows.
|
||||
pub fn head(&self) -> Revision {
|
||||
self.head
|
||||
}
|
||||
|
||||
/// Writes what changed since the last save. Nothing is written if nothing changed.
|
||||
///
|
||||
/// Nothing is written while what is stored comes from a newer version (`Failed(Newer)`, see
|
||||
/// [`Persister::restore`]).
|
||||
///
|
||||
/// The write only happens if nothing else was committed since the persister read or wrote
|
||||
/// last: otherwise it is refused (`Stale`), nothing is written, and the persister has to read
|
||||
/// the storage again ([`Persister::restore`]) before it can write.
|
||||
pub async fn save<S: Storage>(
|
||||
&mut self,
|
||||
storage: &S,
|
||||
snapshot: Snapshot,
|
||||
) -> Result<(), CommitError> {
|
||||
if self.read_only {
|
||||
return Err(StorageError::Newer.into());
|
||||
}
|
||||
let mut plan = self.plan(snapshot);
|
||||
if plan.batch.is_empty() {
|
||||
self.files = plan.snapshot.files;
|
||||
return Ok(());
|
||||
}
|
||||
plan.batch.expect_head = self.head;
|
||||
self.head = storage.commit(&plan.batch).await?;
|
||||
|
||||
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
||||
self.files = plan.snapshot.files;
|
||||
self.chunks = plan.chunks;
|
||||
self.order = Some(plan.snapshot.order);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether something else was committed to the storage since the persister read or wrote it
|
||||
/// last: what it knows of the storage is out of date, and it cannot write until it reads
|
||||
/// again ([`Persister::restore`]).
|
||||
pub async fn is_behind<S: Storage>(&self, storage: &S) -> Result<bool, StorageError> {
|
||||
Ok(storage.head().await? != self.head)
|
||||
}
|
||||
|
||||
fn plan(&self, snapshot: Snapshot) -> Plan {
|
||||
let mut batch = Batch::default();
|
||||
// the chunks that the files use, which the storage has afterwards
|
||||
let mut chunks = HashSet::new();
|
||||
|
||||
for (id, file) in &snapshot.files {
|
||||
let changed = self
|
||||
.files
|
||||
.get(id)
|
||||
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
|
||||
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
|
||||
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
||||
let key = ChunkKey::trackpoints(chunk.id.0);
|
||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||
batch
|
||||
.put_chunks
|
||||
.push((key, encode_trackpoints(chunk, &snapshot.categories)));
|
||||
}
|
||||
}
|
||||
for chunk in file.wpt.chunks() {
|
||||
let key = ChunkKey::waypoints(chunk.id.0);
|
||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||
batch.put_chunks.push((key, encode_waypoints(chunk)));
|
||||
}
|
||||
}
|
||||
if changed || !self.stored_files.contains(id) {
|
||||
batch.put_files.push((*id, encode_file(file)));
|
||||
}
|
||||
}
|
||||
|
||||
batch.delete_files = self
|
||||
.stored_files
|
||||
.iter()
|
||||
.filter(|id| !snapshot.files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
batch.delete_chunks = self.chunks.difference(&chunks).copied().collect();
|
||||
if self.order.as_ref() != Some(&snapshot.order) {
|
||||
batch.order = Some(encode_order(&snapshot.order));
|
||||
}
|
||||
|
||||
Plan {
|
||||
batch,
|
||||
snapshot,
|
||||
chunks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use crate::{
|
||||
Action, ChunkKind, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse,
|
||||
write,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn block_on<F: Future>(future: F) -> F::Output {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
loop {
|
||||
if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory storage that remembers the batches it was given.
|
||||
#[derive(Default)]
|
||||
struct Recording {
|
||||
inner: MemoryStorage,
|
||||
batches: RefCell<Vec<Batch>>,
|
||||
}
|
||||
|
||||
impl Storage for Recording {
|
||||
async fn load(&self) -> Result<StoredData, StorageError> {
|
||||
self.inner.load().await
|
||||
}
|
||||
|
||||
async fn head(&self) -> Result<Revision, StorageError> {
|
||||
self.inner.head().await
|
||||
}
|
||||
|
||||
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||
let revision = self.inner.commit(batch).await?;
|
||||
self.batches.borrow_mut().push(batch.clone());
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||
self.inner.load_settings().await
|
||||
}
|
||||
|
||||
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
|
||||
self.inner.put_setting(key, value).await
|
||||
}
|
||||
|
||||
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
|
||||
self.inner.delete_setting(key).await
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_of(paths: &[&str]) -> Snapshot {
|
||||
let mut snapshot = Snapshot::default();
|
||||
for path in paths {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = parse(&data, &mut snapshot.categories).unwrap();
|
||||
snapshot.order.push(file.id);
|
||||
snapshot.files.insert(file.id, Rc::new(file));
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn gpx(file: &File, categories: &TrackpointCategories) -> Vec<u8> {
|
||||
write(file, categories, ExportOptions::ALL)
|
||||
}
|
||||
|
||||
const FILES: [&str; 3] = [
|
||||
"data/with_tracks_and_segments.gpx",
|
||||
"data/with_waypoint.gpx",
|
||||
"data/with_surface.gpx",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_saved_files_are_restored() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.order, snapshot.order);
|
||||
assert_eq!(restored.categories, snapshot.categories);
|
||||
for file in &restored.files {
|
||||
let original = &snapshot.files[&file.id];
|
||||
assert_eq!(
|
||||
gpx(file, &restored.categories),
|
||||
gpx(original, &snapshot.categories)
|
||||
);
|
||||
// identities that the rest of the engine refers to are kept
|
||||
assert_eq!(file.trk[0].id, original.trk[0].id);
|
||||
assert_eq!(file.trk[0].trkseg[0].id, original.trk[0].trkseg[0].id);
|
||||
assert_eq!(
|
||||
file.trk[0].trkseg[0].chunks()[0].id,
|
||||
original.trk[0].trkseg[0].chunks()[0].id
|
||||
);
|
||||
}
|
||||
// what was just read is saved
|
||||
let again = Snapshot {
|
||||
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||
order: restored.order,
|
||||
categories: restored.categories,
|
||||
};
|
||||
block_on(persister.save(&storage, again)).unwrap();
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_is_written_when_nothing_changed() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_edit_writes_what_it_changed_only() {
|
||||
let storage = Recording::default();
|
||||
let mut snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
let counts = storage.inner.counts();
|
||||
|
||||
// renaming a file writes its record, not its chunks
|
||||
let id = snapshot.order[0];
|
||||
let mut file = (*snapshot.files[&id]).clone();
|
||||
file.info.name = "renamed".into();
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
{
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert!(batch.put_chunks.is_empty());
|
||||
assert!(batch.delete_chunks.is_empty());
|
||||
assert!(batch.order.is_none());
|
||||
}
|
||||
assert_eq!(storage.inner.counts(), counts);
|
||||
|
||||
// changing a trackpoint replaces the chunk that holds it only
|
||||
let mut file = (*snapshot.files[&id]).clone();
|
||||
let chunks_before = file
|
||||
.trk
|
||||
.iter()
|
||||
.flat_map(|t| &t.trkseg)
|
||||
.map(|s| s.chunks().len())
|
||||
.sum::<usize>();
|
||||
file.trk[0].trkseg[0].update(0, |point| point.ele += 1.0);
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
{
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert_eq!(batch.puts(ChunkKind::Trackpoints), 1);
|
||||
assert_eq!(batch.deletes(ChunkKind::Trackpoints), 1);
|
||||
assert_eq!(batch.puts(ChunkKind::Waypoints), 0);
|
||||
}
|
||||
assert_eq!(storage.inner.counts(), counts);
|
||||
assert!(chunks_before >= 1);
|
||||
|
||||
// a deleted file takes its chunks and its record away
|
||||
snapshot.files.remove(&id);
|
||||
snapshot.order.retain(|other| *other != id);
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
let after = storage.inner.counts();
|
||||
assert_eq!(after.files, counts.files - 1);
|
||||
assert!(after.trackpoint_chunks < counts.trackpoint_chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_shared_by_files_are_stored_once_and_stay_shared() {
|
||||
let storage = Recording::default();
|
||||
let mut snapshot = snapshot_of(&["data/with_time.gpx"]);
|
||||
let original = snapshot.files[&snapshot.order[0]].clone();
|
||||
let mut copy = (*original).clone();
|
||||
copy.id = FileId::default();
|
||||
snapshot.order.push(copy.id);
|
||||
snapshot.files.insert(copy.id, Rc::new(copy));
|
||||
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
let chunks = original.trk[0].trkseg[0].chunks().len();
|
||||
assert_eq!(storage.inner.counts().trackpoint_chunks, chunks);
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files.len(), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
&restored.files[0].trk[0].trkseg[0].chunks()[0],
|
||||
&restored.files[1].trk[0].trkseg[0].chunks()[0]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unreadable_files_are_skipped_and_dropped_at_the_next_save() {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
|
||||
// damage a record, and lose a chunk of another file
|
||||
let broken = snapshot.order[0];
|
||||
block_on(storage.inner.commit(&Batch {
|
||||
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
|
||||
expect_head: persister.head(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files.len(), FILES.len() - 1);
|
||||
assert!(restored.files.iter().all(|file| file.id != broken));
|
||||
|
||||
let kept = Snapshot {
|
||||
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||
order: restored.order,
|
||||
categories: restored.categories,
|
||||
};
|
||||
block_on(persister.save(&storage, kept)).unwrap();
|
||||
assert_eq!(storage.inner.counts().files, FILES.len() - 1);
|
||||
}
|
||||
|
||||
/// A storage holding the files, with one blob replaced by `damage`.
|
||||
fn stored_with(damage: impl FnOnce(&Snapshot, &mut Batch)) -> (Recording, Snapshot) {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
let mut batch = Batch::default();
|
||||
damage(&snapshot, &mut batch);
|
||||
batch.expect_head = block_on(storage.inner.head()).unwrap();
|
||||
block_on(storage.inner.commit(&batch)).unwrap();
|
||||
storage.batches.borrow_mut().clear();
|
||||
(storage, snapshot)
|
||||
}
|
||||
|
||||
fn from_the_future() -> Vec<u8> {
|
||||
vec![super::super::codec::LATEST + 1, 1, 2, 3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_what_a_newer_version_wrote_is_left_alone() {
|
||||
type Damage = (&'static str, fn(&Snapshot, &mut Batch));
|
||||
let damages: [Damage; 3] = [
|
||||
("a file", |snapshot, batch| {
|
||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||
}),
|
||||
("a chunk", |snapshot, batch| {
|
||||
let file = &snapshot.files[&snapshot.order[0]];
|
||||
let chunk = &file.trk[0].trkseg[0].chunks()[0];
|
||||
batch
|
||||
.put_chunks
|
||||
.push((ChunkKey::trackpoints(chunk.id.0), from_the_future()));
|
||||
}),
|
||||
("the order", |_, batch| {
|
||||
batch.order = Some(from_the_future())
|
||||
}),
|
||||
];
|
||||
for (what, damage) in damages {
|
||||
let (storage, snapshot) = stored_with(damage);
|
||||
let before = storage.inner.counts();
|
||||
let mut persister = Persister::default();
|
||||
let result = block_on(persister.restore(&storage));
|
||||
assert_eq!(result.unwrap_err(), StorageError::Newer, "{what}");
|
||||
|
||||
// whatever is saved afterwards is refused: the newer version needs its data
|
||||
let mut changed = snapshot.clone();
|
||||
changed.order.clear();
|
||||
changed.files.clear();
|
||||
let result = block_on(persister.save(&storage, changed));
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(CommitError::Failed(StorageError::Newer)),
|
||||
"{what}"
|
||||
);
|
||||
assert!(storage.batches.borrow().is_empty(), "{what}");
|
||||
assert_eq!(storage.inner.counts(), before, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restoring_again_forgets_that_the_storage_was_newer() {
|
||||
let (storage, _) = stored_with(|snapshot, batch| {
|
||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||
});
|
||||
let mut persister = Persister::default();
|
||||
assert!(block_on(persister.restore(&storage)).is_err());
|
||||
|
||||
// the same persister, on a storage that is not newer
|
||||
let clean = Recording::default();
|
||||
block_on(persister.restore(&clean)).unwrap();
|
||||
block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap();
|
||||
assert_eq!(clean.inner.counts().files, FILES.len());
|
||||
}
|
||||
|
||||
/// Opens a storage as a tab does: the persister, and the state of the engine it gives.
|
||||
fn open(storage: &Recording) -> (Persister, Snapshot) {
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(storage)).unwrap();
|
||||
let snapshot = Snapshot {
|
||||
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||
order: restored.order,
|
||||
categories: restored.categories,
|
||||
};
|
||||
(persister, snapshot)
|
||||
}
|
||||
|
||||
/// Adds a file that has just been read to the state of an engine.
|
||||
fn add_file(snapshot: &mut Snapshot, path: &str) -> FileId {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let file = parse(&data, &mut snapshot.categories).unwrap();
|
||||
let id = file.id;
|
||||
snapshot.order.push(id);
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_files_saved_by_engines_with_other_categories_read_the_same() {
|
||||
// a second engine opens the storage, so its categories come from what it read, in the
|
||||
// order it read them; it adds a file that has names of its own
|
||||
let storage = Recording::default();
|
||||
let first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]);
|
||||
block_on(Persister::default().save(&storage, first.clone())).unwrap();
|
||||
|
||||
let (mut persister, mut second) = open(&storage);
|
||||
let added = add_file(&mut second, "data/with_surface.gpx");
|
||||
block_on(persister.save(&storage, second.clone())).unwrap();
|
||||
|
||||
// the first engine reads it all again, and every file means what it did for its engine
|
||||
let (_, reader) = open(&storage);
|
||||
assert_eq!(reader.files.len(), 3);
|
||||
for (id, file) in &reader.files {
|
||||
let (original, categories) = match first.files.get(id) {
|
||||
Some(original) => (original, &first.categories),
|
||||
None => (&second.files[id], &second.categories),
|
||||
};
|
||||
assert_eq!(gpx(file, &reader.categories), gpx(original, categories));
|
||||
}
|
||||
assert!(reader.files.contains_key(&added));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_the_head_follows_the_commits_of_the_persister() {
|
||||
let storage = Recording::default();
|
||||
let mut persister = Persister::default();
|
||||
assert_eq!(persister.head(), Revision(0));
|
||||
|
||||
let mut snapshot = snapshot_of(&FILES);
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
assert_eq!(persister.head(), Revision(1));
|
||||
assert_eq!(block_on(storage.head()).unwrap(), Revision(1));
|
||||
|
||||
// nothing to write is no commit
|
||||
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||
assert_eq!(persister.head(), Revision(1));
|
||||
assert_eq!(storage.batches.borrow().len(), 1);
|
||||
|
||||
let id = snapshot.order[0];
|
||||
let mut file = (*snapshot.files[&id]).clone();
|
||||
file.info.name = "renamed".into();
|
||||
snapshot.files.insert(id, Rc::new(file));
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
assert_eq!(persister.head(), Revision(2));
|
||||
// a persister that opens the storage starts from its head
|
||||
assert_eq!(open(&storage).0.head(), Revision(2));
|
||||
assert_eq!(storage.batches.borrow()[1].expect_head, Revision(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_tab_that_is_behind_cannot_write() {
|
||||
// two tabs open the same file; one edits a point, the other renames the file
|
||||
let storage = Recording::default();
|
||||
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
|
||||
let ((mut a, mut snap_a), (mut b, mut snap_b)) = (open(&storage), open(&storage));
|
||||
let id = snap_a.order[0];
|
||||
|
||||
let mut file = (*snap_a.files[&id]).clone();
|
||||
file.trk[0].trkseg[0].update(0, |p| p.ele += 100.0);
|
||||
snap_a.files.insert(id, Rc::new(file));
|
||||
block_on(a.save(&storage, snap_a.clone())).unwrap();
|
||||
let stored = block_on(storage.load()).unwrap();
|
||||
|
||||
let mut file = (*snap_b.files[&id]).clone();
|
||||
file.info.name = "renamed by B".into();
|
||||
snap_b.files.insert(id, Rc::new(file));
|
||||
assert_eq!(
|
||||
block_on(b.save(&storage, snap_b)),
|
||||
Err(CommitError::Stale { head: Revision(2) })
|
||||
);
|
||||
// nothing was written, the file is still readable, and the persister did not move
|
||||
assert_eq!(block_on(storage.load()).unwrap(), stored);
|
||||
assert_eq!(b.head(), Revision(1));
|
||||
let (_, reader) = open(&storage);
|
||||
assert_eq!(reader.files.len(), 1);
|
||||
assert_eq!(reader.files[&id].info.name, snap_a.files[&id].info.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_a_tab_that_is_behind_reads_the_storage_again_and_writes_again() {
|
||||
let storage = Recording::default();
|
||||
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
|
||||
let ((mut a, mut snap_a), (mut b, snap_b)) = (open(&storage), open(&storage));
|
||||
let id = snap_a.order[0];
|
||||
assert!(!block_on(b.is_behind(&storage)).unwrap());
|
||||
|
||||
let mut file = (*snap_a.files[&id]).clone();
|
||||
file.info.name = "renamed by A".into();
|
||||
snap_a.files.insert(id, Rc::new(file));
|
||||
block_on(a.save(&storage, snap_a)).unwrap();
|
||||
assert!(block_on(b.is_behind(&storage)).unwrap());
|
||||
assert!(!block_on(a.is_behind(&storage)).unwrap());
|
||||
|
||||
let restored = block_on(b.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files[0].info.name, "renamed by A");
|
||||
assert!(!block_on(b.is_behind(&storage)).unwrap());
|
||||
let mut snapshot = snap_b;
|
||||
snapshot.files.insert(id, restored.files[0].clone());
|
||||
assert!(block_on(b.save(&storage, snapshot)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunks_shared_by_files_still_are_after_the_names_are_read() {
|
||||
let storage = Recording::default();
|
||||
let mut snapshot = snapshot_of(&["data/with_surface.gpx"]);
|
||||
let original = snapshot.files[&snapshot.order[0]].clone();
|
||||
let mut copy = (*original).clone();
|
||||
copy.id = FileId::default();
|
||||
snapshot.order.push(copy.id);
|
||||
snapshot.files.insert(copy.id, Rc::new(copy));
|
||||
block_on(Persister::default().save(&storage, snapshot)).unwrap();
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files.len(), 2);
|
||||
assert!(Rc::ptr_eq(
|
||||
&restored.files[0].trk[0].trkseg[0].chunks()[0],
|
||||
&restored.files[1].trk[0].trkseg[0].chunks()[0]
|
||||
));
|
||||
assert!(!restored.categories.surface.names().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nothing_but_the_files_and_the_order_is_stored() {
|
||||
let storage = Recording::default();
|
||||
block_on(Persister::default().save(&storage, snapshot_of(&FILES))).unwrap();
|
||||
// the categories are in the chunks: a storage with no more than these is complete
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data.files.len(), FILES.len());
|
||||
assert!(data.order.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_an_engine_starts_from_what_is_restored() {
|
||||
let storage = Recording::default();
|
||||
let mut engine = Engine::default();
|
||||
let load = |engine: &mut Engine, path: &str| {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
engine
|
||||
.execute(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
})))
|
||||
.unwrap()
|
||||
.changed
|
||||
};
|
||||
for path in FILES {
|
||||
assert!(load(&mut engine, path));
|
||||
}
|
||||
let snapshot = engine.snapshot();
|
||||
assert_eq!(snapshot.files.len(), FILES.len());
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
let mut engine = Engine::default();
|
||||
let outcome = engine.restore(restored);
|
||||
assert_eq!(engine.snapshot().order, snapshot.order);
|
||||
assert_eq!(outcome.diff.unwrap().added.len(), FILES.len());
|
||||
assert!(outcome.order_changed);
|
||||
for id in &snapshot.order {
|
||||
assert!(engine.file_structure(id).is_some());
|
||||
}
|
||||
|
||||
// the files that were restored are not undone
|
||||
assert!(!engine.can_undo());
|
||||
assert!(!engine.execute(Action::Undo).unwrap().changed);
|
||||
assert!(load(&mut engine, FILES[0]));
|
||||
assert!(engine.can_undo());
|
||||
assert!(engine.execute(Action::Undo).unwrap().changed);
|
||||
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
||||
assert!(!engine.can_undo());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user