mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
fix windows build continued
This commit is contained in:
1 parent
17b5c1d567
commit
77c542d9aa
11 files changed
+955
-905
No files matched your search
@@ -7,6 +7,15 @@ edition = "2024"
|
||||
[lib]
|
||||
name = "gpx_studio_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
# the tests are in `tests/app`: on Windows, a test binary of the library or of the app could not
|
||||
# start without the manifest, which `build.rs` only gives to the binaries of `tests`
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[[bin]]
|
||||
name = "gpx-studio-desktop"
|
||||
path = "src/main.rs"
|
||||
test = false
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
fn main() {
|
||||
// On Windows, the dialogs need Common Controls v6, which a binary only gets with a manifest that
|
||||
// asks for it: tauri-build gives one to the app, but not to the test binaries, which would stop
|
||||
// with STATUS_ENTRYPOINT_NOT_FOUND before running any test. This gives them the same manifest
|
||||
// (`-tests` only reaches the binaries of `tests`, hence the tests are there).
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
|
||||
{
|
||||
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("windows-test.manifest");
|
||||
println!("cargo:rerun-if-changed={}", manifest.display());
|
||||
println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED");
|
||||
println!(
|
||||
"cargo:rustc-link-arg-tests=/MANIFESTINPUT:{}",
|
||||
manifest.display()
|
||||
);
|
||||
}
|
||||
tauri_build::build()
|
||||
}
|
||||
+6
-9
@@ -3,20 +3,20 @@
|
||||
|
||||
mod bytes;
|
||||
mod commands;
|
||||
mod state;
|
||||
mod store;
|
||||
mod workspace;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
pub mod workspace;
|
||||
|
||||
use commands::*;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// The engine of the app: each file has its own history, so that undoing only concerns the
|
||||
/// selected files, and opening or closing a file is not something to undo.
|
||||
pub(crate) type Engine = gpx_engine::Engine<gpx_engine::PerFileHistory>;
|
||||
pub type Engine = gpx_engine::Engine<gpx_engine::PerFileHistory>;
|
||||
|
||||
/// The GPX files among what the system gave to the app (the arguments of the command line, when a
|
||||
/// file is opened with it).
|
||||
fn gpx_paths(arguments: impl IntoIterator<Item = String>) -> Vec<std::path::PathBuf> {
|
||||
pub fn gpx_paths(arguments: impl IntoIterator<Item = String>) -> Vec<std::path::PathBuf> {
|
||||
arguments
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
@@ -108,7 +108,7 @@ fn menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::me
|
||||
}
|
||||
|
||||
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
|
||||
fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||
pub fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||
builder.invoke_handler(tauri::generate_handler![
|
||||
// selection
|
||||
selection::select,
|
||||
@@ -234,6 +234,3 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
|
||||
system::reveal_path,
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -280,13 +280,6 @@ impl AppState {
|
||||
})
|
||||
}
|
||||
|
||||
/// An engine that keeps its data in a directory of its own, and has nobody to tell.
|
||||
#[cfg(test)]
|
||||
pub fn temporary() -> Self {
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-{}", uuid::Uuid::new_v4()));
|
||||
Self::open(directory, Arc::new(|_, _| {})).expect("the storage can be opened")
|
||||
}
|
||||
|
||||
/// Opens files that the system gave to the app, see `Core::open_external`.
|
||||
pub fn open_external(&self, paths: Vec<PathBuf>) {
|
||||
self.core(move |core| core.open_external(&paths));
|
||||
|
||||
@@ -217,69 +217,3 @@ impl Storage for FileStorage {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Engine;
|
||||
use gpx_engine::{Action, Command, Load, LoadFiles, Persister};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn directory() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("gpx-studio-store-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn engine_with_a_file() -> Engine {
|
||||
let mut engine = Engine::default();
|
||||
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
|
||||
engine
|
||||
.execute(Action::Edit(Command::LoadFiles(LoadFiles {
|
||||
files: vec![Load {
|
||||
data: &data,
|
||||
name: "simple",
|
||||
}],
|
||||
})))
|
||||
.unwrap();
|
||||
engine
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_files_and_the_settings_between_sessions() {
|
||||
let root = directory();
|
||||
let engine = engine_with_a_file();
|
||||
let order = engine.order().to_vec();
|
||||
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap();
|
||||
pollster::block_on(storage.put_setting("units", "\"metric\"")).unwrap();
|
||||
|
||||
// another session
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap();
|
||||
let mut other = Engine::default();
|
||||
other.restore(restored);
|
||||
assert_eq!(other.order(), order);
|
||||
assert_eq!(
|
||||
other.file_statistics(&order[0]).unwrap().total_distance,
|
||||
engine.file_statistics(&order[0]).unwrap().total_distance
|
||||
);
|
||||
assert_eq!(storage.settings()["units"], "\"metric\"");
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_commit_that_is_not_based_on_the_head() {
|
||||
let root = directory();
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
let batch = Batch {
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pollster::block_on(storage.commit(&batch)), Ok(Revision(1)));
|
||||
assert_eq!(
|
||||
pollster::block_on(storage.commit(&batch)),
|
||||
Err(CommitError::Stale { head: Revision(1) })
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
+2
-812
@@ -148,7 +148,7 @@ pub struct Entry {
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
fn is_gpx(path: &Path) -> bool {
|
||||
pub fn is_gpx(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("gpx"))
|
||||
@@ -156,7 +156,7 @@ fn is_gpx(path: &Path) -> bool {
|
||||
|
||||
/// Compares names the way people read them: the numbers in them by their value ("track 2" before
|
||||
/// "track 10"), the rest regardless of case.
|
||||
fn natural_cmp(a: &str, b: &str) -> Ordering {
|
||||
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
|
||||
let (mut a, mut b) = (a.chars().peekable(), b.chars().peekable());
|
||||
loop {
|
||||
let order = match (a.peek().copied(), b.peek().copied()) {
|
||||
@@ -1040,813 +1040,3 @@ impl Workspace {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpx_engine::SelectMode;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn gpx(name: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><metadata><name>{name}</name></metadata><trk><name>{name}</name><trkseg><trkpt lat="50.0" lon="4.0"><ele>10</ele></trkpt><trkpt lat="50.1" lon="4.1"><ele>20</ele></trkpt></trkseg></trk></gpx>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// The GPX files of a folder and of the folders in it.
|
||||
pub(super) fn gpx_files(folder: &Path) -> Vec<PathBuf> {
|
||||
let mut found = vec![];
|
||||
for entry in fs::read_dir(folder).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
found.extend(gpx_files(&path));
|
||||
} else if is_gpx(&path) {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
folder: PathBuf,
|
||||
engine: Engine,
|
||||
workspace: Workspace,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let folder =
|
||||
canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&folder).unwrap();
|
||||
Self {
|
||||
folder,
|
||||
engine: Engine::default(),
|
||||
workspace: Workspace::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, name: &str, title: &str) -> PathBuf {
|
||||
let path = self.folder.join(name);
|
||||
fs::write(&path, gpx(title)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Opens the folder, and all the files that are in it.
|
||||
fn open_folder(&mut self) {
|
||||
let folder = self.folder.clone();
|
||||
self.workspace.open_folder(&mut self.engine, &folder, false);
|
||||
let files = gpx_files(&folder);
|
||||
self.workspace.open_files(&mut self.engine, &files, false);
|
||||
}
|
||||
|
||||
fn reconcile(&mut self, changed: Option<&[PathBuf]>) -> Option<api::Outcome> {
|
||||
self.workspace.reconcile(&mut self.engine, changed)
|
||||
}
|
||||
|
||||
fn save(&mut self, ids: &[Uuid]) {
|
||||
self.workspace
|
||||
.save(&mut self.engine, ids, &BTreeMap::new(), None)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn ids(&self) -> Vec<Uuid> {
|
||||
self.engine.order().iter().map(|id| id.0).collect()
|
||||
}
|
||||
|
||||
fn id_named(&self, name: &str) -> Uuid {
|
||||
self.ids()
|
||||
.into_iter()
|
||||
.find(|id| self.name(*id) == name)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn name(&self, id: Uuid) -> String {
|
||||
self.engine
|
||||
.file_structure(&FileId(id))
|
||||
.unwrap()
|
||||
.name
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// What the file holds: the name of its track (its own name is the one of the file).
|
||||
fn content(&self, id: Uuid) -> String {
|
||||
self.engine.file_structure(&FileId(id)).unwrap().tracks[0]
|
||||
.info
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn rename(&mut self, id: Uuid, name: &str) {
|
||||
api::select(&mut self.engine, &[id], SelectMode::Replace);
|
||||
assert!(!api::metadata(&mut self.engine, name, "").has_error());
|
||||
}
|
||||
|
||||
fn status(&self, id: Uuid) -> FileStatus {
|
||||
self.workspace
|
||||
.state(&self.engine)
|
||||
.files
|
||||
.remove(&id.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.folder);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opened_file_is_clean_until_it_is_edited_and_clean_again_once_saved() {
|
||||
let mut fx = Fixture::new();
|
||||
let path = fx.write("a.gpx", "a");
|
||||
fx.workspace
|
||||
.open_files(&mut fx.engine, std::slice::from_ref(&path), true);
|
||||
let id = fx.ids()[0];
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert_eq!(fx.status(id).path.as_deref(), path.to_str());
|
||||
|
||||
fx.rename(id, "renamed");
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
// nothing is written before it is saved
|
||||
assert!(fs::read_to_string(&path).unwrap().contains(">a<"));
|
||||
|
||||
fx.save(&[id]);
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert!(fs::read_to_string(&path).unwrap().contains(">renamed<"));
|
||||
|
||||
// going back before the save is an edit again
|
||||
api::select(&mut fx.engine, &[id], SelectMode::Replace);
|
||||
api::undo(&mut fx.engine);
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
api::redo(&mut fx.engine);
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_on_the_disk_is_unsaved_until_it_is_given_a_place() {
|
||||
let mut fx = Fixture::new();
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
let id = fx.ids()[0];
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert_eq!(
|
||||
fx.workspace.unlinked(&fx.engine, &[id]),
|
||||
[(id, "fresh".to_string())]
|
||||
);
|
||||
// nowhere to put it without being told
|
||||
fx.save(&[id]);
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
|
||||
let path = fx.folder.join("fresh.gpx");
|
||||
let paths = BTreeMap::from([(id, path.clone())]);
|
||||
fx.workspace
|
||||
.save(&mut fx.engine, &[id], &paths, None)
|
||||
.unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert!(fx.workspace.unlinked(&fx.engine, &[id]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_files_without_a_place_go_in_a_folder_with_names_that_are_free() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("fresh.gpx", "already there");
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
api::new_file(&mut fx.engine, "a/b: c", None, None, None, false);
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
let ids = fx.ids();
|
||||
let folder = fx.folder.clone();
|
||||
fx.workspace
|
||||
.save(&mut fx.engine, &ids, &BTreeMap::new(), Some(&folder))
|
||||
.unwrap();
|
||||
for name in ["fresh (2).gpx", "fresh (3).gpx", "a_b_ c.gpx"] {
|
||||
assert!(fx.folder.join(name).exists(), "{name}");
|
||||
}
|
||||
assert!(ids.iter().all(|id| !fx.workspace.is_dirty(&fx.engine, *id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_this_app_writes_is_not_a_change_of_the_disk() {
|
||||
let mut fx = Fixture::new();
|
||||
let path = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
api::select(&mut fx.engine, &[id], SelectMode::Replace);
|
||||
assert!(!api::style(&mut fx.engine, Some("#ff0000"), None, None).has_error());
|
||||
fx.save(&[id]);
|
||||
assert!(fx.reconcile(Some(&[path])).is_none());
|
||||
assert!(!fx.status(id).replaced && !fx.status(id).dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_of_the_files_outside_the_open_folder_are_watched_too() {
|
||||
let mut fx = Fixture::new();
|
||||
let inside = fx.write("in.gpx", "in");
|
||||
let elsewhere = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&elsewhere).unwrap();
|
||||
let (one, two) = (elsewhere.join("one.gpx"), elsewhere.join("two.gpx"));
|
||||
fs::write(&one, gpx("one")).unwrap();
|
||||
fs::write(&two, gpx("two")).unwrap();
|
||||
let folder = fx.folder.clone();
|
||||
|
||||
// no folder: only the folder of the files
|
||||
fx.workspace.open_files(&mut fx.engine, &[one, two], false);
|
||||
assert_eq!(fx.workspace.watched(), [(elsewhere.clone(), false)]);
|
||||
|
||||
// the open folder as a whole, and the file in it needs nothing more
|
||||
fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
fx.workspace.open_files(&mut fx.engine, &[inside], false);
|
||||
assert_eq!(
|
||||
fx.workspace.watched(),
|
||||
[(folder, true), (elsewhere.clone(), false)]
|
||||
);
|
||||
|
||||
// closing the files stops it
|
||||
let ids = fx.ids();
|
||||
fx.workspace.close_files(&mut fx.engine, &ids, true);
|
||||
assert_eq!(fx.workspace.watched(), [(fx.folder.clone(), true)]);
|
||||
fs::remove_dir_all(elsewhere).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_folder_does_not_open_its_files() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
let folder = fx.folder.clone();
|
||||
let outcome = fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
assert!(!outcome.changed);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert_eq!(fx.workspace.folder.as_deref(), Some(folder.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_made_renamed_and_moved_and_the_open_files_follow() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
let root = fx.folder.clone();
|
||||
let path_of = |fx: &Fixture| PathBuf::from(fx.status(id).path.unwrap());
|
||||
|
||||
let first = fx.workspace.create_folder(&root, "New folder").unwrap();
|
||||
let second = fx.workspace.create_folder(&root, "New folder").unwrap();
|
||||
assert_eq!(first, root.join("New folder"));
|
||||
assert_eq!(second, root.join("New folder 2"));
|
||||
|
||||
fx.workspace.move_paths(&[a], &first).unwrap();
|
||||
assert_eq!(path_of(&fx), first.join("a.gpx"));
|
||||
assert!(first.join("a.gpx").exists() && !fx.status(id).dirty);
|
||||
|
||||
let trips = fx.workspace.rename_path(&first, "Trips").unwrap();
|
||||
assert_eq!(path_of(&fx), root.join("Trips/a.gpx"));
|
||||
// a folder does not go into itself, nor onto something else
|
||||
assert!(
|
||||
fx.workspace
|
||||
.move_paths(std::slice::from_ref(&trips), &trips)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
fx.workspace.rename_path(&trips, "New folder 2"),
|
||||
Err(NAME_TAKEN.to_owned())
|
||||
);
|
||||
fx.workspace.move_paths(&[trips], &second).unwrap();
|
||||
assert_eq!(path_of(&fx), second.join("Trips/a.gpx"));
|
||||
|
||||
// a file keeps its extension
|
||||
let closed = fx.write("c.gpx", "c");
|
||||
assert_eq!(
|
||||
fx.workspace.rename_path(&closed, "d").unwrap(),
|
||||
root.join("d.gpx")
|
||||
);
|
||||
// the open folder and what is not in it are left alone
|
||||
assert!(fx.workspace.rename_path(&root, "x").is_err());
|
||||
let elsewhere = canonical(&std::env::temp_dir());
|
||||
assert!(fx.workspace.rename_path(&elsewhere, "x").is_err());
|
||||
assert!(fx.workspace.create_folder(&elsewhere, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_files_are_closed_unless_they_have_edits() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
let b = fx.write("b.gpx", "b");
|
||||
fx.open_folder();
|
||||
let (id_a, id_b) = (fx.id_named("a"), fx.id_named("b"));
|
||||
api::select(&mut fx.engine, &[id_b], SelectMode::Replace);
|
||||
assert!(!api::style(&mut fx.engine, Some("#ff0000"), None, None).has_error());
|
||||
|
||||
let outcome = fx
|
||||
.workspace
|
||||
.delete_paths(&mut fx.engine, &[a.clone(), b.clone()], |paths| {
|
||||
paths.iter().for_each(|path| fs::remove_file(path).unwrap());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(fx.ids(), [id_b]);
|
||||
assert!(!fx.ids().contains(&id_a));
|
||||
let status = fx.status(id_b);
|
||||
assert!(status.dirty && status.missing);
|
||||
assert!(!a.exists() && !b.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_of_the_folder_shows_folders_first_and_only_gpx_files() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("b.gpx", "b");
|
||||
fx.write("A.GPX", "a");
|
||||
fx.write("b 10.gpx", "b 10");
|
||||
fx.write("b 2.gpx", "b 2");
|
||||
fx.write("notes.txt", "not a track");
|
||||
fx.write(".hidden.gpx", "hidden");
|
||||
fs::create_dir(fx.folder.join("zoo")).unwrap();
|
||||
fs::create_dir(fx.folder.join("Alps")).unwrap();
|
||||
fs::create_dir(fx.folder.join(".git")).unwrap();
|
||||
fx.write("zoo/c.gpx", "c");
|
||||
let folder = fx.folder.clone();
|
||||
fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
|
||||
let names = |entries: Vec<Entry>| -> Vec<(String, bool)> {
|
||||
entries.into_iter().map(|e| (e.name, e.is_dir)).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
names(fx.workspace.list_folder(&folder).unwrap()),
|
||||
[
|
||||
("Alps".to_string(), true),
|
||||
("zoo".to_string(), true),
|
||||
("A.GPX".to_string(), false),
|
||||
("b.gpx".to_string(), false),
|
||||
("b 2.gpx".to_string(), false),
|
||||
("b 10.gpx".to_string(), false),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
names(fx.workspace.list_folder(&folder.join("zoo")).unwrap()),
|
||||
[("c.gpx".to_string(), false)]
|
||||
);
|
||||
// only what is in the open folder can be listed
|
||||
let elsewhere = canonical(&std::env::temp_dir());
|
||||
assert!(fx.workspace.list_folder(&elsewhere).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_sorted_by_the_numbers_they_hold() {
|
||||
let mut names = vec![
|
||||
"track 10",
|
||||
"track 2",
|
||||
"Track 1",
|
||||
"track",
|
||||
"track 02b",
|
||||
"track 2a",
|
||||
"b",
|
||||
"A",
|
||||
];
|
||||
names.sort_by(|a, b| natural_cmp(a, b).then_with(|| a.cmp(b)));
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"A",
|
||||
"b",
|
||||
"track",
|
||||
"Track 1",
|
||||
"track 2",
|
||||
"track 2a",
|
||||
"track 02b",
|
||||
"track 10",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_edits_is_read_again_when_the_disk_changes() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
|
||||
fx.write("a.gpx", "theirs");
|
||||
let outcome = fx.reconcile(Some(std::slice::from_ref(&a))).unwrap();
|
||||
assert!(outcome.changed);
|
||||
// the same file, now the one on the disk
|
||||
assert_eq!(fx.ids(), [id]);
|
||||
assert_eq!(fx.content(id), "theirs");
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.replaced);
|
||||
// looking again changes nothing
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_edits_is_flagged_when_the_disk_changes_and_not_read_again() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "mine");
|
||||
|
||||
fx.write("a.gpx", "theirs");
|
||||
let outcome = fx.reconcile(Some(std::slice::from_ref(&a))).unwrap();
|
||||
assert!(!outcome.changed);
|
||||
assert_eq!(fx.name(id), "mine");
|
||||
let status = fx.status(id);
|
||||
assert!(status.dirty && status.replaced && !status.missing);
|
||||
// looking again changes nothing
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_none());
|
||||
|
||||
// the disk goes back to what was read: only the edits are left
|
||||
fx.write("a.gpx", "a");
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_some());
|
||||
let status = fx.status(id);
|
||||
assert!(status.dirty && !status.replaced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_user_can_take_the_disk_instead_of_the_edits() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "mine");
|
||||
fx.write("a.gpx", "theirs");
|
||||
fx.reconcile(None);
|
||||
assert!(fx.status(id).replaced);
|
||||
|
||||
fx.workspace.reload(&mut fx.engine, &[id]).unwrap();
|
||||
assert_eq!(fx.content(id), "theirs");
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.replaced);
|
||||
// and it is one undo step
|
||||
api::select(&mut fx.engine, &[id], SelectMode::Replace);
|
||||
api::undo(&mut fx.engine);
|
||||
assert_eq!(fx.content(id), "mine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_overwrites_what_replaced_the_file() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "mine");
|
||||
fx.write("a.gpx", "theirs");
|
||||
fx.reconcile(None);
|
||||
assert!(fx.status(id).replaced);
|
||||
|
||||
fx.save(&[id]);
|
||||
assert!(fs::read_to_string(a).unwrap().contains(">mine<"));
|
||||
assert!(!fx.status(id).replaced && !fx.status(id).dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_gone_from_the_disk_stays_open_and_dirty_until_it_is_saved_again() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
|
||||
fs::remove_file(&a).unwrap();
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_some());
|
||||
assert_eq!(fx.ids(), [id]);
|
||||
let status = fx.status(id);
|
||||
assert!(status.dirty && status.missing && status.path.is_some());
|
||||
|
||||
// saving puts it back
|
||||
fx.save(&[id]);
|
||||
assert!(a.exists());
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_comes_back_different_is_read_again() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fs::remove_file(&a).unwrap();
|
||||
fx.reconcile(None);
|
||||
assert!(fx.status(id).missing);
|
||||
|
||||
fx.write("a.gpx", "back");
|
||||
fx.reconcile(None);
|
||||
assert_eq!(fx.content(id), "back");
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_changed_while_the_app_was_closed_is_found_when_it_starts() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.write("a.gpx", "changed");
|
||||
assert!(fx.reconcile(None).is_some());
|
||||
assert_eq!(fx.content(id), "changed");
|
||||
fs::remove_file(a).unwrap();
|
||||
assert!(fx.reconcile(None).is_some());
|
||||
assert!(fx.status(id).missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_made_from_a_file_of_the_folder_are_saved_next_to_it() {
|
||||
let mut fx = Fixture::new();
|
||||
fs::create_dir(fx.folder.join("sub")).unwrap();
|
||||
fx.write("sub/b.gpx", "b");
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let b = fx.id_named("b");
|
||||
let sub = fx.folder.join("sub");
|
||||
assert_eq!(fx.workspace.source_dir(&[b]), Some(canonical(&sub)));
|
||||
|
||||
let before = fx.ids();
|
||||
api::select(&mut fx.engine, &[b], SelectMode::Replace);
|
||||
let outcome = api::duplicate(&mut fx.engine);
|
||||
let copy = fx
|
||||
.ids()
|
||||
.into_iter()
|
||||
.find(|id| !before.contains(id))
|
||||
.unwrap();
|
||||
fx.workspace.inherit(outcome.added(), &canonical(&sub));
|
||||
assert_eq!(fx.status(copy).folder.as_deref(), canonical(&sub).to_str());
|
||||
assert!(fx.status(copy).path.is_none() && fx.status(copy).dirty);
|
||||
// nobody needs to be asked where it goes
|
||||
assert!(fx.workspace.unlinked(&fx.engine, &[copy]).is_empty());
|
||||
|
||||
fx.save(&[copy]);
|
||||
assert!(sub.join("b (2).gpx").exists());
|
||||
let status = fx.status(copy);
|
||||
assert!(!status.dirty && status.folder.is_none());
|
||||
assert_eq!(
|
||||
status.path.as_deref(),
|
||||
canonical(&sub.join("b (2).gpx")).to_str()
|
||||
);
|
||||
// the copy of the copy is next to it as well
|
||||
assert_eq!(fx.workspace.source_dir(&[copy]), Some(canonical(&sub)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_outside_the_open_folder_hands_nothing_down() {
|
||||
let mut fx = Fixture::new();
|
||||
let elsewhere = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&elsewhere).unwrap();
|
||||
let outside = elsewhere.join("o.gpx");
|
||||
fs::write(&outside, gpx("o")).unwrap();
|
||||
let folder = fx.folder.clone();
|
||||
fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
fx.workspace
|
||||
.open_files(&mut fx.engine, std::slice::from_ref(&outside), true);
|
||||
let id = fx.ids()[0];
|
||||
assert_eq!(fx.workspace.source_dir(&[id]), None);
|
||||
// a new file with no source is not linked to a folder by `source_dir`: the caller falls back
|
||||
fs::remove_dir_all(elsewhere).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_files_asks_what_to_do_with_the_unsaved_ones() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.write("b.gpx", "b");
|
||||
fx.open_folder();
|
||||
let (a, b) = (fx.id_named("a"), fx.id_named("b"));
|
||||
fx.rename(a, "edited");
|
||||
assert_eq!(fx.workspace.unsaved(&fx.engine, &[a, b]), [a]);
|
||||
|
||||
// without a choice what has edits stays
|
||||
let outcome = fx.workspace.close_files(&mut fx.engine, &[a, b], false);
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(fx.ids(), [a]);
|
||||
|
||||
// saved first
|
||||
fx.save(&[a]);
|
||||
fx.workspace.close_files(&mut fx.engine, &[a], false);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert!(
|
||||
fs::read_to_string(fx.folder.join("a.gpx"))
|
||||
.unwrap()
|
||||
.contains("edited")
|
||||
);
|
||||
assert!(fx.workspace.links.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_files_can_discard_their_edits() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let a = fx.ids()[0];
|
||||
fx.rename(a, "edited");
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
let fresh = fx.id_named("fresh");
|
||||
fx.workspace.close_files(&mut fx.engine, &[a, fresh], true);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert!(
|
||||
fs::read_to_string(fx.folder.join("a.gpx"))
|
||||
.unwrap()
|
||||
.contains(">a<")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_folder_closes_every_file() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
assert_eq!(fx.ids().len(), 2);
|
||||
|
||||
// the new file has nowhere to go: it has to be discarded
|
||||
let outcome = fx.workspace.close_folder(&mut fx.engine, false);
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(fx.ids().len(), 1);
|
||||
assert!(fx.workspace.folder.is_none());
|
||||
|
||||
let outcome = fx.workspace.close_folder(&mut fx.engine, true);
|
||||
assert!(outcome.changed);
|
||||
assert!(fx.ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_another_folder_closes_the_first_with_all_its_files() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let a = fx.ids()[0];
|
||||
fx.rename(a, "edited");
|
||||
let other = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&other).unwrap();
|
||||
|
||||
// the edits stay, nothing is lost without a choice
|
||||
fx.workspace.open_folder(&mut fx.engine, &other, false);
|
||||
assert_eq!(fx.ids(), [a]);
|
||||
assert_eq!(fx.workspace.folder.as_deref(), Some(other.as_path()));
|
||||
|
||||
let first = fx.folder.clone();
|
||||
fx.workspace.open_folder(&mut fx.engine, &first, true);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert!(
|
||||
fs::read_to_string(fx.folder.join("a.gpx"))
|
||||
.unwrap()
|
||||
.contains(">a<")
|
||||
);
|
||||
fs::remove_dir_all(other).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_the_same_folder_again_changes_nothing() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let folder = fx.folder.clone();
|
||||
let outcome = fx.workspace.open_folder(&mut fx.engine, &folder, true);
|
||||
assert!(!outcome.changed);
|
||||
assert_eq!(fx.ids().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_stops_at_the_file_and_leaves_it_dirty() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "edited");
|
||||
// the place of the file is taken by a folder
|
||||
fs::remove_file(fx.folder.join("a.gpx")).unwrap();
|
||||
fs::create_dir(fx.folder.join("a.gpx")).unwrap();
|
||||
let result = fx
|
||||
.workspace
|
||||
.save(&mut fx.engine, &[id], &BTreeMap::new(), None);
|
||||
assert!(result.is_err());
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_as_gives_the_file_another_place() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.write("b.gpx", "b");
|
||||
fx.open_folder();
|
||||
let id = fx.id_named("a");
|
||||
let other = fx.id_named("b");
|
||||
fx.rename(id, "edited");
|
||||
|
||||
let target = fx.folder.join("copy.gpx");
|
||||
fx.workspace.save_as(&mut fx.engine, id, &target).unwrap();
|
||||
assert!(fs::read_to_string(&target).unwrap().contains("edited"));
|
||||
assert!(fs::read_to_string(a).unwrap().contains(">a<"));
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty);
|
||||
assert_eq!(status.path.as_deref(), canonical(&target).to_str());
|
||||
|
||||
// the place of another open file is not available
|
||||
let taken = fx.folder.join("b.gpx");
|
||||
assert!(fx.workspace.save_as(&mut fx.engine, id, &taken).is_err());
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, other));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod restart_tests {
|
||||
use gpx_engine::Persister;
|
||||
|
||||
use super::*;
|
||||
use crate::store::FileStorage;
|
||||
|
||||
/// What the app does when it starts again: the engine and the links are read from what was
|
||||
/// kept.
|
||||
fn restart(engine: &Engine, workspace: &Workspace, root: &Path) -> (Engine, Workspace) {
|
||||
let storage = FileStorage::open(root).unwrap();
|
||||
pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap();
|
||||
let mut restarted = Engine::default();
|
||||
let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap();
|
||||
restarted.restore(restored);
|
||||
let links = serde_json::to_string(workspace).unwrap();
|
||||
(restarted, serde_json::from_str(&links).unwrap())
|
||||
}
|
||||
|
||||
fn setup() -> (PathBuf, Engine, Workspace, Vec<Uuid>) {
|
||||
let root = std::env::temp_dir().join(format!("gpx-restart-{}", Uuid::new_v4()));
|
||||
let folder = root.join("tracks");
|
||||
fs::create_dir_all(&folder).unwrap();
|
||||
for name in ["with_tracks", "with_time", "with_waypoint"] {
|
||||
let _ = fs::copy(
|
||||
format!("../gpx-rs/data/{name}.gpx"),
|
||||
folder.join(format!("{name}.gpx")),
|
||||
);
|
||||
}
|
||||
let mut engine = Engine::default();
|
||||
let mut workspace = Workspace::default();
|
||||
workspace.open_folder(&mut engine, &folder, false);
|
||||
workspace.open_files(&mut engine, &tests::gpx_files(&folder), false);
|
||||
let ids = engine.order().iter().map(|id| id.0).collect();
|
||||
(root, engine, workspace, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_were_not_edited_are_not_unsaved_after_a_restart() {
|
||||
let (root, engine, workspace, ids) = setup();
|
||||
assert!(ids.len() >= 2);
|
||||
let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store"));
|
||||
// the files are the same, but the engine knows their contents by new identities
|
||||
assert!(ids.iter().any(|id| workspace.is_dirty(&restarted, *id)));
|
||||
workspace.verify(&restarted);
|
||||
assert!(ids.iter().all(|id| !workspace.is_dirty(&restarted, *id)));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_were_edited_are_still_unsaved_after_a_restart() {
|
||||
let (root, mut engine, workspace, ids) = setup();
|
||||
api::select(&mut engine, &[ids[0]], gpx_engine::SelectMode::Replace);
|
||||
api::metadata(&mut engine, "edited", "");
|
||||
assert!(workspace.is_dirty(&engine, ids[0]));
|
||||
let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store"));
|
||||
workspace.verify(&restarted);
|
||||
assert!(workspace.is_dirty(&restarted, ids[0]));
|
||||
assert!(
|
||||
ids[1..]
|
||||
.iter()
|
||||
.all(|id| !workspace.is_dirty(&restarted, *id))
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_made_before_the_content_was_kept_are_checked_against_the_disk() {
|
||||
let (root, mut engine, mut workspace, ids) = setup();
|
||||
api::select(&mut engine, &[ids[0]], gpx_engine::SelectMode::Replace);
|
||||
api::metadata(&mut engine, "edited", "");
|
||||
for link in workspace.links.values_mut() {
|
||||
link.export_hash = 0;
|
||||
}
|
||||
let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store"));
|
||||
workspace.verify(&restarted);
|
||||
assert!(workspace.is_dirty(&restarted, ids[0]));
|
||||
assert!(
|
||||
ids[1..]
|
||||
.iter()
|
||||
.all(|id| !workspace.is_dirty(&restarted, *id))
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_of_new_files_and_the_old_flag_name_survive_a_restart() {
|
||||
let (root, engine, mut workspace, ids) = setup();
|
||||
let folder = workspace.folder.clone().unwrap();
|
||||
let fresh = Uuid::new_v4();
|
||||
workspace.inherit(&[fresh.to_string()], &folder);
|
||||
workspace.links.get_mut(&ids[0]).unwrap().replaced = true;
|
||||
let json = serde_json::to_string(&workspace).unwrap();
|
||||
// links written by the previous version had `conflict`, which is the same thing
|
||||
let json = json.replace("\"replaced\"", "\"conflict\"");
|
||||
assert!(json.contains("\"conflict\":true"));
|
||||
let read: Workspace = serde_json::from_str(&json).unwrap();
|
||||
assert!(read.links[&ids[0]].replaced);
|
||||
assert_eq!(read.dirs.get(&fresh), Some(&folder));
|
||||
let _ = engine;
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ struct App(WebviewWindow<tauri::test::MockRuntime>);
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
let app =
|
||||
crate::register_commands(mock_builder().manage(crate::state::AppState::temporary()))
|
||||
let app = gpx_studio_desktop_lib::register_commands(
|
||||
mock_builder().manage(crate::temporary_state()),
|
||||
)
|
||||
.build(mock_context(noop_assets()))
|
||||
.unwrap();
|
||||
let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||
@@ -311,8 +312,8 @@ fn keeps_settings_for_the_session() {
|
||||
|
||||
#[test]
|
||||
fn the_files_are_still_there_in_the_next_session() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
use gpx_studio_desktop_lib::state::AppState;
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("gpx-studio-session-{}", uuid::Uuid::new_v4()));
|
||||
@@ -347,14 +348,14 @@ fn only_gpx_files_that_exist_are_taken_from_what_the_system_gives() {
|
||||
text.to_string_lossy().into_owned(),
|
||||
directory.join("missing.gpx").to_string_lossy().into_owned(),
|
||||
];
|
||||
assert_eq!(crate::gpx_paths(arguments), [track]);
|
||||
assert_eq!(gpx_studio_desktop_lib::gpx_paths(arguments), [track]);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_opened_with_the_app_is_opened_selected_and_announced() {
|
||||
use crate::state::AppState;
|
||||
use gpx_engine::api;
|
||||
use gpx_studio_desktop_lib::state::AppState;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-open-{}", uuid::Uuid::new_v4()));
|
||||
@@ -441,7 +442,7 @@ fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
|
||||
|
||||
let (events, received) = mpsc::channel();
|
||||
let directory = std::env::temp_dir().join(format!("gpx-store-{}", uuid::Uuid::new_v4()));
|
||||
let state = crate::state::AppState::open(
|
||||
let state = gpx_studio_desktop_lib::state::AppState::open(
|
||||
directory.clone(),
|
||||
std::sync::Arc::new(move |name, _| {
|
||||
let _ = events.send(name.to_string());
|
||||
@@ -461,8 +462,8 @@ fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
|
||||
});
|
||||
let id = uuid::Uuid::parse_str(&id).unwrap();
|
||||
// the name comes from the path, which does not change: the content does
|
||||
let signature = |state: &crate::state::AppState| {
|
||||
state.core(move |core| crate::workspace::signature(&core.engine, id))
|
||||
let signature = |state: &gpx_studio_desktop_lib::state::AppState| {
|
||||
state.core(move |core| gpx_studio_desktop_lib::workspace::signature(&core.engine, id))
|
||||
};
|
||||
let before = signature(&state);
|
||||
|
||||
@@ -483,7 +484,7 @@ fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
|
||||
|
||||
#[test]
|
||||
fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() {
|
||||
let state = crate::state::AppState::temporary();
|
||||
let state = crate::temporary_state();
|
||||
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(folder.join("sub")).unwrap();
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("sub/track.gpx")).unwrap();
|
||||
@@ -528,7 +529,7 @@ fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() {
|
||||
|
||||
#[test]
|
||||
fn the_parts_of_a_split_are_numbered_from_one() {
|
||||
let state = crate::state::AppState::temporary();
|
||||
let state = crate::temporary_state();
|
||||
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("track.gpx")).unwrap();
|
||||
@@ -649,7 +650,7 @@ fn a_file_keeps_its_name_and_its_file_is_renamed_apart() {
|
||||
|
||||
// the name of another file is not taken
|
||||
let error = app.error("rename_file", json!({ "fileId": id, "name": "taken" }));
|
||||
assert_eq!(error, crate::workspace::NAME_TAKEN);
|
||||
assert_eq!(error, gpx_studio_desktop_lib::workspace::NAME_TAKEN);
|
||||
assert!(folder.join("moved.gpx").exists());
|
||||
|
||||
// a name is one that a file can have
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The tests of the desktop app. They are integration tests, in a single binary, rather than unit
|
||||
//! tests: on Windows, only test binaries can be given the manifest that the app needs to start
|
||||
//! (see `build.rs`).
|
||||
|
||||
mod commands;
|
||||
mod store;
|
||||
mod workspace;
|
||||
|
||||
use gpx_studio_desktop_lib::state::AppState;
|
||||
|
||||
/// An engine that keeps its data in a directory of its own, and has nobody to tell.
|
||||
fn temporary_state() -> AppState {
|
||||
let directory = std::env::temp_dir().join(format!("gpx-studio-{}", uuid::Uuid::new_v4()));
|
||||
AppState::open(directory, std::sync::Arc::new(|_, _| {})).expect("the storage can be opened")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The storage of the engine on the file system (`store`).
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpx_engine::{
|
||||
Action, Batch, Command, CommitError, Load, LoadFiles, Persister, Revision, Storage,
|
||||
};
|
||||
use gpx_studio_desktop_lib::Engine;
|
||||
use gpx_studio_desktop_lib::store::FileStorage;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn directory() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("gpx-studio-store-{}", Uuid::new_v4()))
|
||||
}
|
||||
|
||||
fn engine_with_a_file() -> Engine {
|
||||
let mut engine = Engine::default();
|
||||
let data = std::fs::read("../gpx-rs/data/simple.gpx").unwrap();
|
||||
engine
|
||||
.execute(Action::Edit(Command::LoadFiles(LoadFiles {
|
||||
files: vec![Load {
|
||||
data: &data,
|
||||
name: "simple",
|
||||
}],
|
||||
})))
|
||||
.unwrap();
|
||||
engine
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_files_and_the_settings_between_sessions() {
|
||||
let root = directory();
|
||||
let engine = engine_with_a_file();
|
||||
let order = engine.order().to_vec();
|
||||
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap();
|
||||
pollster::block_on(storage.put_setting("units", "\"metric\"")).unwrap();
|
||||
|
||||
// another session
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap();
|
||||
let mut other = Engine::default();
|
||||
other.restore(restored);
|
||||
assert_eq!(other.order(), order);
|
||||
assert_eq!(
|
||||
other.file_statistics(&order[0]).unwrap().total_distance,
|
||||
engine.file_statistics(&order[0]).unwrap().total_distance
|
||||
);
|
||||
assert_eq!(storage.settings()["units"], "\"metric\"");
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_commit_that_is_not_based_on_the_head() {
|
||||
let root = directory();
|
||||
let storage = FileStorage::open(&root).unwrap();
|
||||
let batch = Batch {
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pollster::block_on(storage.commit(&batch)), Ok(Revision(1)));
|
||||
assert_eq!(
|
||||
pollster::block_on(storage.commit(&batch)),
|
||||
Err(CommitError::Stale { head: Revision(1) })
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
//! The places of the files of the engine on the disk (`workspace`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gpx_engine::{FileId, SelectMode, api};
|
||||
use gpx_studio_desktop_lib::Engine;
|
||||
use gpx_studio_desktop_lib::workspace::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn gpx(name: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1"><metadata><name>{name}</name></metadata><trk><name>{name}</name><trkseg><trkpt lat="50.0" lon="4.0"><ele>10</ele></trkpt><trkpt lat="50.1" lon="4.1"><ele>20</ele></trkpt></trkseg></trk></gpx>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// The GPX files of a folder and of the folders in it.
|
||||
pub fn gpx_files(folder: &Path) -> Vec<PathBuf> {
|
||||
let mut found = vec![];
|
||||
for entry in fs::read_dir(folder).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
found.extend(gpx_files(&path));
|
||||
} else if is_gpx(&path) {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
folder: PathBuf,
|
||||
engine: Engine,
|
||||
workspace: Workspace,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let folder = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&folder).unwrap();
|
||||
Self {
|
||||
folder,
|
||||
engine: Engine::default(),
|
||||
workspace: Workspace::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, name: &str, title: &str) -> PathBuf {
|
||||
let path = self.folder.join(name);
|
||||
fs::write(&path, gpx(title)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Opens the folder, and all the files that are in it.
|
||||
fn open_folder(&mut self) {
|
||||
let folder = self.folder.clone();
|
||||
self.workspace.open_folder(&mut self.engine, &folder, false);
|
||||
let files = gpx_files(&folder);
|
||||
self.workspace.open_files(&mut self.engine, &files, false);
|
||||
}
|
||||
|
||||
fn reconcile(&mut self, changed: Option<&[PathBuf]>) -> Option<api::Outcome> {
|
||||
self.workspace.reconcile(&mut self.engine, changed)
|
||||
}
|
||||
|
||||
fn save(&mut self, ids: &[Uuid]) {
|
||||
self.workspace
|
||||
.save(&mut self.engine, ids, &BTreeMap::new(), None)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn ids(&self) -> Vec<Uuid> {
|
||||
self.engine.order().iter().map(|id| id.0).collect()
|
||||
}
|
||||
|
||||
fn id_named(&self, name: &str) -> Uuid {
|
||||
self.ids()
|
||||
.into_iter()
|
||||
.find(|id| self.name(*id) == name)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn name(&self, id: Uuid) -> String {
|
||||
self.engine
|
||||
.file_structure(&FileId(id))
|
||||
.unwrap()
|
||||
.name
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// What the file holds: the name of its track (its own name is the one of the file).
|
||||
fn content(&self, id: Uuid) -> String {
|
||||
self.engine.file_structure(&FileId(id)).unwrap().tracks[0]
|
||||
.info
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn rename(&mut self, id: Uuid, name: &str) {
|
||||
api::select(&mut self.engine, &[id], SelectMode::Replace);
|
||||
assert!(!api::metadata(&mut self.engine, name, "").has_error());
|
||||
}
|
||||
|
||||
fn status(&self, id: Uuid) -> FileStatus {
|
||||
self.workspace
|
||||
.state(&self.engine)
|
||||
.files
|
||||
.remove(&id.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.folder);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opened_file_is_clean_until_it_is_edited_and_clean_again_once_saved() {
|
||||
let mut fx = Fixture::new();
|
||||
let path = fx.write("a.gpx", "a");
|
||||
fx.workspace
|
||||
.open_files(&mut fx.engine, std::slice::from_ref(&path), true);
|
||||
let id = fx.ids()[0];
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert_eq!(fx.status(id).path.as_deref(), path.to_str());
|
||||
|
||||
fx.rename(id, "renamed");
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
// nothing is written before it is saved
|
||||
assert!(fs::read_to_string(&path).unwrap().contains(">a<"));
|
||||
|
||||
fx.save(&[id]);
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert!(fs::read_to_string(&path).unwrap().contains(">renamed<"));
|
||||
|
||||
// going back before the save is an edit again
|
||||
api::select(&mut fx.engine, &[id], SelectMode::Replace);
|
||||
api::undo(&mut fx.engine);
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
api::redo(&mut fx.engine);
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_on_the_disk_is_unsaved_until_it_is_given_a_place() {
|
||||
let mut fx = Fixture::new();
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
let id = fx.ids()[0];
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert_eq!(
|
||||
fx.workspace.unlinked(&fx.engine, &[id]),
|
||||
[(id, "fresh".to_string())]
|
||||
);
|
||||
// nowhere to put it without being told
|
||||
fx.save(&[id]);
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
|
||||
let path = fx.folder.join("fresh.gpx");
|
||||
let paths = BTreeMap::from([(id, path.clone())]);
|
||||
fx.workspace
|
||||
.save(&mut fx.engine, &[id], &paths, None)
|
||||
.unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, id));
|
||||
assert!(fx.workspace.unlinked(&fx.engine, &[id]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_files_without_a_place_go_in_a_folder_with_names_that_are_free() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("fresh.gpx", "already there");
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
api::new_file(&mut fx.engine, "a/b: c", None, None, None, false);
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
let ids = fx.ids();
|
||||
let folder = fx.folder.clone();
|
||||
fx.workspace
|
||||
.save(&mut fx.engine, &ids, &BTreeMap::new(), Some(&folder))
|
||||
.unwrap();
|
||||
for name in ["fresh (2).gpx", "fresh (3).gpx", "a_b_ c.gpx"] {
|
||||
assert!(fx.folder.join(name).exists(), "{name}");
|
||||
}
|
||||
assert!(ids.iter().all(|id| !fx.workspace.is_dirty(&fx.engine, *id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_this_app_writes_is_not_a_change_of_the_disk() {
|
||||
let mut fx = Fixture::new();
|
||||
let path = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
api::select(&mut fx.engine, &[id], SelectMode::Replace);
|
||||
assert!(!api::style(&mut fx.engine, Some("#ff0000"), None, None).has_error());
|
||||
fx.save(&[id]);
|
||||
assert!(fx.reconcile(Some(&[path])).is_none());
|
||||
assert!(!fx.status(id).replaced && !fx.status(id).dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_of_the_files_outside_the_open_folder_are_watched_too() {
|
||||
let mut fx = Fixture::new();
|
||||
let inside = fx.write("in.gpx", "in");
|
||||
let elsewhere = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&elsewhere).unwrap();
|
||||
let (one, two) = (elsewhere.join("one.gpx"), elsewhere.join("two.gpx"));
|
||||
fs::write(&one, gpx("one")).unwrap();
|
||||
fs::write(&two, gpx("two")).unwrap();
|
||||
let folder = fx.folder.clone();
|
||||
|
||||
// no folder: only the folder of the files
|
||||
fx.workspace.open_files(&mut fx.engine, &[one, two], false);
|
||||
assert_eq!(fx.workspace.watched(), [(elsewhere.clone(), false)]);
|
||||
|
||||
// the open folder as a whole, and the file in it needs nothing more
|
||||
fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
fx.workspace.open_files(&mut fx.engine, &[inside], false);
|
||||
assert_eq!(
|
||||
fx.workspace.watched(),
|
||||
[(folder, true), (elsewhere.clone(), false)]
|
||||
);
|
||||
|
||||
// closing the files stops it
|
||||
let ids = fx.ids();
|
||||
fx.workspace.close_files(&mut fx.engine, &ids, true);
|
||||
assert_eq!(fx.workspace.watched(), [(fx.folder.clone(), true)]);
|
||||
fs::remove_dir_all(elsewhere).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_folder_does_not_open_its_files() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
let folder = fx.folder.clone();
|
||||
let outcome = fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
assert!(!outcome.changed);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert_eq!(fx.workspace.folder.as_deref(), Some(folder.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_made_renamed_and_moved_and_the_open_files_follow() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
let root = fx.folder.clone();
|
||||
let path_of = |fx: &Fixture| PathBuf::from(fx.status(id).path.unwrap());
|
||||
|
||||
let first = fx.workspace.create_folder(&root, "New folder").unwrap();
|
||||
let second = fx.workspace.create_folder(&root, "New folder").unwrap();
|
||||
assert_eq!(first, root.join("New folder"));
|
||||
assert_eq!(second, root.join("New folder 2"));
|
||||
|
||||
fx.workspace.move_paths(&[a], &first).unwrap();
|
||||
assert_eq!(path_of(&fx), first.join("a.gpx"));
|
||||
assert!(first.join("a.gpx").exists() && !fx.status(id).dirty);
|
||||
|
||||
let trips = fx.workspace.rename_path(&first, "Trips").unwrap();
|
||||
assert_eq!(path_of(&fx), root.join("Trips/a.gpx"));
|
||||
// a folder does not go into itself, nor onto something else
|
||||
assert!(
|
||||
fx.workspace
|
||||
.move_paths(std::slice::from_ref(&trips), &trips)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
fx.workspace.rename_path(&trips, "New folder 2"),
|
||||
Err(NAME_TAKEN.to_owned())
|
||||
);
|
||||
fx.workspace.move_paths(&[trips], &second).unwrap();
|
||||
assert_eq!(path_of(&fx), second.join("Trips/a.gpx"));
|
||||
|
||||
// a file keeps its extension
|
||||
let closed = fx.write("c.gpx", "c");
|
||||
assert_eq!(
|
||||
fx.workspace.rename_path(&closed, "d").unwrap(),
|
||||
root.join("d.gpx")
|
||||
);
|
||||
// the open folder and what is not in it are left alone
|
||||
assert!(fx.workspace.rename_path(&root, "x").is_err());
|
||||
let elsewhere = canonical(&std::env::temp_dir());
|
||||
assert!(fx.workspace.rename_path(&elsewhere, "x").is_err());
|
||||
assert!(fx.workspace.create_folder(&elsewhere, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_files_are_closed_unless_they_have_edits() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
let b = fx.write("b.gpx", "b");
|
||||
fx.open_folder();
|
||||
let (id_a, id_b) = (fx.id_named("a"), fx.id_named("b"));
|
||||
api::select(&mut fx.engine, &[id_b], SelectMode::Replace);
|
||||
assert!(!api::style(&mut fx.engine, Some("#ff0000"), None, None).has_error());
|
||||
|
||||
let outcome = fx
|
||||
.workspace
|
||||
.delete_paths(&mut fx.engine, &[a.clone(), b.clone()], |paths| {
|
||||
paths.iter().for_each(|path| fs::remove_file(path).unwrap());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(fx.ids(), [id_b]);
|
||||
assert!(!fx.ids().contains(&id_a));
|
||||
let status = fx.status(id_b);
|
||||
assert!(status.dirty && status.missing);
|
||||
assert!(!a.exists() && !b.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_of_the_folder_shows_folders_first_and_only_gpx_files() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("b.gpx", "b");
|
||||
fx.write("A.GPX", "a");
|
||||
fx.write("b 10.gpx", "b 10");
|
||||
fx.write("b 2.gpx", "b 2");
|
||||
fx.write("notes.txt", "not a track");
|
||||
fx.write(".hidden.gpx", "hidden");
|
||||
fs::create_dir(fx.folder.join("zoo")).unwrap();
|
||||
fs::create_dir(fx.folder.join("Alps")).unwrap();
|
||||
fs::create_dir(fx.folder.join(".git")).unwrap();
|
||||
fx.write("zoo/c.gpx", "c");
|
||||
let folder = fx.folder.clone();
|
||||
fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
|
||||
let names = |entries: Vec<Entry>| -> Vec<(String, bool)> {
|
||||
entries.into_iter().map(|e| (e.name, e.is_dir)).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
names(fx.workspace.list_folder(&folder).unwrap()),
|
||||
[
|
||||
("Alps".to_string(), true),
|
||||
("zoo".to_string(), true),
|
||||
("A.GPX".to_string(), false),
|
||||
("b.gpx".to_string(), false),
|
||||
("b 2.gpx".to_string(), false),
|
||||
("b 10.gpx".to_string(), false),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
names(fx.workspace.list_folder(&folder.join("zoo")).unwrap()),
|
||||
[("c.gpx".to_string(), false)]
|
||||
);
|
||||
// only what is in the open folder can be listed
|
||||
let elsewhere = canonical(&std::env::temp_dir());
|
||||
assert!(fx.workspace.list_folder(&elsewhere).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_sorted_by_the_numbers_they_hold() {
|
||||
let mut names = vec![
|
||||
"track 10",
|
||||
"track 2",
|
||||
"Track 1",
|
||||
"track",
|
||||
"track 02b",
|
||||
"track 2a",
|
||||
"b",
|
||||
"A",
|
||||
];
|
||||
names.sort_by(|a, b| natural_cmp(a, b).then_with(|| a.cmp(b)));
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"A",
|
||||
"b",
|
||||
"track",
|
||||
"Track 1",
|
||||
"track 2",
|
||||
"track 2a",
|
||||
"track 02b",
|
||||
"track 10",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_edits_is_read_again_when_the_disk_changes() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
|
||||
fx.write("a.gpx", "theirs");
|
||||
let outcome = fx.reconcile(Some(std::slice::from_ref(&a))).unwrap();
|
||||
assert!(outcome.changed);
|
||||
// the same file, now the one on the disk
|
||||
assert_eq!(fx.ids(), [id]);
|
||||
assert_eq!(fx.content(id), "theirs");
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.replaced);
|
||||
// looking again changes nothing
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_edits_is_flagged_when_the_disk_changes_and_not_read_again() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "mine");
|
||||
|
||||
fx.write("a.gpx", "theirs");
|
||||
let outcome = fx.reconcile(Some(std::slice::from_ref(&a))).unwrap();
|
||||
assert!(!outcome.changed);
|
||||
assert_eq!(fx.name(id), "mine");
|
||||
let status = fx.status(id);
|
||||
assert!(status.dirty && status.replaced && !status.missing);
|
||||
// looking again changes nothing
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_none());
|
||||
|
||||
// the disk goes back to what was read: only the edits are left
|
||||
fx.write("a.gpx", "a");
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_some());
|
||||
let status = fx.status(id);
|
||||
assert!(status.dirty && !status.replaced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_user_can_take_the_disk_instead_of_the_edits() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "mine");
|
||||
fx.write("a.gpx", "theirs");
|
||||
fx.reconcile(None);
|
||||
assert!(fx.status(id).replaced);
|
||||
|
||||
fx.workspace.reload(&mut fx.engine, &[id]).unwrap();
|
||||
assert_eq!(fx.content(id), "theirs");
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.replaced);
|
||||
// and it is one undo step
|
||||
api::select(&mut fx.engine, &[id], SelectMode::Replace);
|
||||
api::undo(&mut fx.engine);
|
||||
assert_eq!(fx.content(id), "mine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_overwrites_what_replaced_the_file() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "mine");
|
||||
fx.write("a.gpx", "theirs");
|
||||
fx.reconcile(None);
|
||||
assert!(fx.status(id).replaced);
|
||||
|
||||
fx.save(&[id]);
|
||||
assert!(fs::read_to_string(a).unwrap().contains(">mine<"));
|
||||
assert!(!fx.status(id).replaced && !fx.status(id).dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_gone_from_the_disk_stays_open_and_dirty_until_it_is_saved_again() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
|
||||
fs::remove_file(&a).unwrap();
|
||||
assert!(fx.reconcile(Some(std::slice::from_ref(&a))).is_some());
|
||||
assert_eq!(fx.ids(), [id]);
|
||||
let status = fx.status(id);
|
||||
assert!(status.dirty && status.missing && status.path.is_some());
|
||||
|
||||
// saving puts it back
|
||||
fx.save(&[id]);
|
||||
assert!(a.exists());
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_comes_back_different_is_read_again() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fs::remove_file(&a).unwrap();
|
||||
fx.reconcile(None);
|
||||
assert!(fx.status(id).missing);
|
||||
|
||||
fx.write("a.gpx", "back");
|
||||
fx.reconcile(None);
|
||||
assert_eq!(fx.content(id), "back");
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty && !status.missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_changed_while_the_app_was_closed_is_found_when_it_starts() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.write("a.gpx", "changed");
|
||||
assert!(fx.reconcile(None).is_some());
|
||||
assert_eq!(fx.content(id), "changed");
|
||||
fs::remove_file(a).unwrap();
|
||||
assert!(fx.reconcile(None).is_some());
|
||||
assert!(fx.status(id).missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_made_from_a_file_of_the_folder_are_saved_next_to_it() {
|
||||
let mut fx = Fixture::new();
|
||||
fs::create_dir(fx.folder.join("sub")).unwrap();
|
||||
fx.write("sub/b.gpx", "b");
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let b = fx.id_named("b");
|
||||
let sub = fx.folder.join("sub");
|
||||
assert_eq!(fx.workspace.source_dir(&[b]), Some(canonical(&sub)));
|
||||
|
||||
let before = fx.ids();
|
||||
api::select(&mut fx.engine, &[b], SelectMode::Replace);
|
||||
let outcome = api::duplicate(&mut fx.engine);
|
||||
let copy = fx
|
||||
.ids()
|
||||
.into_iter()
|
||||
.find(|id| !before.contains(id))
|
||||
.unwrap();
|
||||
fx.workspace.inherit(outcome.added(), &canonical(&sub));
|
||||
assert_eq!(fx.status(copy).folder.as_deref(), canonical(&sub).to_str());
|
||||
assert!(fx.status(copy).path.is_none() && fx.status(copy).dirty);
|
||||
// nobody needs to be asked where it goes
|
||||
assert!(fx.workspace.unlinked(&fx.engine, &[copy]).is_empty());
|
||||
|
||||
fx.save(&[copy]);
|
||||
assert!(sub.join("b (2).gpx").exists());
|
||||
let status = fx.status(copy);
|
||||
assert!(!status.dirty && status.folder.is_none());
|
||||
assert_eq!(
|
||||
status.path.as_deref(),
|
||||
canonical(&sub.join("b (2).gpx")).to_str()
|
||||
);
|
||||
// the copy of the copy is next to it as well
|
||||
assert_eq!(fx.workspace.source_dir(&[copy]), Some(canonical(&sub)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_outside_the_open_folder_hands_nothing_down() {
|
||||
let mut fx = Fixture::new();
|
||||
let elsewhere = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&elsewhere).unwrap();
|
||||
let outside = elsewhere.join("o.gpx");
|
||||
fs::write(&outside, gpx("o")).unwrap();
|
||||
let folder = fx.folder.clone();
|
||||
fx.workspace.open_folder(&mut fx.engine, &folder, false);
|
||||
fx.workspace
|
||||
.open_files(&mut fx.engine, std::slice::from_ref(&outside), true);
|
||||
let id = fx.ids()[0];
|
||||
assert_eq!(fx.workspace.source_dir(&[id]), None);
|
||||
// a new file with no source is not linked to a folder by `source_dir`: the caller falls back
|
||||
fs::remove_dir_all(elsewhere).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_files_asks_what_to_do_with_the_unsaved_ones() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.write("b.gpx", "b");
|
||||
fx.open_folder();
|
||||
let (a, b) = (fx.id_named("a"), fx.id_named("b"));
|
||||
fx.rename(a, "edited");
|
||||
assert_eq!(fx.workspace.unsaved(&fx.engine, &[a, b]), [a]);
|
||||
|
||||
// without a choice what has edits stays
|
||||
let outcome = fx.workspace.close_files(&mut fx.engine, &[a, b], false);
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(fx.ids(), [a]);
|
||||
|
||||
// saved first
|
||||
fx.save(&[a]);
|
||||
fx.workspace.close_files(&mut fx.engine, &[a], false);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert!(
|
||||
fs::read_to_string(fx.folder.join("a.gpx"))
|
||||
.unwrap()
|
||||
.contains("edited")
|
||||
);
|
||||
assert!(fx.workspace.links.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_files_can_discard_their_edits() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let a = fx.ids()[0];
|
||||
fx.rename(a, "edited");
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
let fresh = fx.id_named("fresh");
|
||||
fx.workspace.close_files(&mut fx.engine, &[a, fresh], true);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert!(
|
||||
fs::read_to_string(fx.folder.join("a.gpx"))
|
||||
.unwrap()
|
||||
.contains(">a<")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_folder_closes_every_file() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
api::new_file(&mut fx.engine, "fresh", None, None, None, false);
|
||||
assert_eq!(fx.ids().len(), 2);
|
||||
|
||||
// the new file has nowhere to go: it has to be discarded
|
||||
let outcome = fx.workspace.close_folder(&mut fx.engine, false);
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(fx.ids().len(), 1);
|
||||
assert!(fx.workspace.folder.is_none());
|
||||
|
||||
let outcome = fx.workspace.close_folder(&mut fx.engine, true);
|
||||
assert!(outcome.changed);
|
||||
assert!(fx.ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_another_folder_closes_the_first_with_all_its_files() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let a = fx.ids()[0];
|
||||
fx.rename(a, "edited");
|
||||
let other = canonical(&std::env::temp_dir()).join(format!("gpx-ws-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&other).unwrap();
|
||||
|
||||
// the edits stay, nothing is lost without a choice
|
||||
fx.workspace.open_folder(&mut fx.engine, &other, false);
|
||||
assert_eq!(fx.ids(), [a]);
|
||||
assert_eq!(fx.workspace.folder.as_deref(), Some(other.as_path()));
|
||||
|
||||
let first = fx.folder.clone();
|
||||
fx.workspace.open_folder(&mut fx.engine, &first, true);
|
||||
assert!(fx.ids().is_empty());
|
||||
assert!(
|
||||
fs::read_to_string(fx.folder.join("a.gpx"))
|
||||
.unwrap()
|
||||
.contains(">a<")
|
||||
);
|
||||
fs::remove_dir_all(other).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_the_same_folder_again_changes_nothing() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let folder = fx.folder.clone();
|
||||
let outcome = fx.workspace.open_folder(&mut fx.engine, &folder, true);
|
||||
assert!(!outcome.changed);
|
||||
assert_eq!(fx.ids().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_stops_at_the_file_and_leaves_it_dirty() {
|
||||
let mut fx = Fixture::new();
|
||||
fx.write("a.gpx", "a");
|
||||
fx.open_folder();
|
||||
let id = fx.ids()[0];
|
||||
fx.rename(id, "edited");
|
||||
// the place of the file is taken by a folder
|
||||
fs::remove_file(fx.folder.join("a.gpx")).unwrap();
|
||||
fs::create_dir(fx.folder.join("a.gpx")).unwrap();
|
||||
let result = fx
|
||||
.workspace
|
||||
.save(&mut fx.engine, &[id], &BTreeMap::new(), None);
|
||||
assert!(result.is_err());
|
||||
assert!(fx.workspace.is_dirty(&fx.engine, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_as_gives_the_file_another_place() {
|
||||
let mut fx = Fixture::new();
|
||||
let a = fx.write("a.gpx", "a");
|
||||
fx.write("b.gpx", "b");
|
||||
fx.open_folder();
|
||||
let id = fx.id_named("a");
|
||||
let other = fx.id_named("b");
|
||||
fx.rename(id, "edited");
|
||||
|
||||
let target = fx.folder.join("copy.gpx");
|
||||
fx.workspace.save_as(&mut fx.engine, id, &target).unwrap();
|
||||
assert!(fs::read_to_string(&target).unwrap().contains("edited"));
|
||||
assert!(fs::read_to_string(a).unwrap().contains(">a<"));
|
||||
let status = fx.status(id);
|
||||
assert!(!status.dirty);
|
||||
assert_eq!(status.path.as_deref(), canonical(&target).to_str());
|
||||
|
||||
// the place of another open file is not available
|
||||
let taken = fx.folder.join("b.gpx");
|
||||
assert!(fx.workspace.save_as(&mut fx.engine, id, &taken).is_err());
|
||||
assert!(!fx.workspace.is_dirty(&fx.engine, other));
|
||||
}
|
||||
|
||||
mod restart {
|
||||
use gpx_engine::Persister;
|
||||
|
||||
use super::*;
|
||||
use gpx_studio_desktop_lib::store::FileStorage;
|
||||
|
||||
/// What the app does when it starts again: the engine and the links are read from what was
|
||||
/// kept.
|
||||
fn restart(engine: &Engine, workspace: &Workspace, root: &Path) -> (Engine, Workspace) {
|
||||
let storage = FileStorage::open(root).unwrap();
|
||||
pollster::block_on(Persister::default().save(&storage, engine.snapshot())).unwrap();
|
||||
let mut restarted = Engine::default();
|
||||
let restored = pollster::block_on(Persister::default().restore(&storage)).unwrap();
|
||||
restarted.restore(restored);
|
||||
let links = serde_json::to_string(workspace).unwrap();
|
||||
(restarted, serde_json::from_str(&links).unwrap())
|
||||
}
|
||||
|
||||
fn setup() -> (PathBuf, Engine, Workspace, Vec<Uuid>) {
|
||||
let root = std::env::temp_dir().join(format!("gpx-restart-{}", Uuid::new_v4()));
|
||||
let folder = root.join("tracks");
|
||||
fs::create_dir_all(&folder).unwrap();
|
||||
for name in ["with_tracks", "with_time", "with_waypoint"] {
|
||||
let _ = fs::copy(
|
||||
format!("../gpx-rs/data/{name}.gpx"),
|
||||
folder.join(format!("{name}.gpx")),
|
||||
);
|
||||
}
|
||||
let mut engine = Engine::default();
|
||||
let mut workspace = Workspace::default();
|
||||
workspace.open_folder(&mut engine, &folder, false);
|
||||
workspace.open_files(&mut engine, &gpx_files(&folder), false);
|
||||
let ids = engine.order().iter().map(|id| id.0).collect();
|
||||
(root, engine, workspace, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_were_not_edited_are_not_unsaved_after_a_restart() {
|
||||
let (root, engine, workspace, ids) = setup();
|
||||
assert!(ids.len() >= 2);
|
||||
let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store"));
|
||||
// the files are the same, but the engine knows their contents by new identities
|
||||
assert!(ids.iter().any(|id| workspace.is_dirty(&restarted, *id)));
|
||||
workspace.verify(&restarted);
|
||||
assert!(ids.iter().all(|id| !workspace.is_dirty(&restarted, *id)));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_were_edited_are_still_unsaved_after_a_restart() {
|
||||
let (root, mut engine, workspace, ids) = setup();
|
||||
api::select(&mut engine, &[ids[0]], gpx_engine::SelectMode::Replace);
|
||||
api::metadata(&mut engine, "edited", "");
|
||||
assert!(workspace.is_dirty(&engine, ids[0]));
|
||||
let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store"));
|
||||
workspace.verify(&restarted);
|
||||
assert!(workspace.is_dirty(&restarted, ids[0]));
|
||||
assert!(
|
||||
ids[1..]
|
||||
.iter()
|
||||
.all(|id| !workspace.is_dirty(&restarted, *id))
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_made_before_the_content_was_kept_are_checked_against_the_disk() {
|
||||
let (root, mut engine, mut workspace, ids) = setup();
|
||||
api::select(&mut engine, &[ids[0]], gpx_engine::SelectMode::Replace);
|
||||
api::metadata(&mut engine, "edited", "");
|
||||
for link in workspace.links.values_mut() {
|
||||
link.export_hash = 0;
|
||||
}
|
||||
let (restarted, mut workspace) = restart(&engine, &workspace, &root.join("store"));
|
||||
workspace.verify(&restarted);
|
||||
assert!(workspace.is_dirty(&restarted, ids[0]));
|
||||
assert!(
|
||||
ids[1..]
|
||||
.iter()
|
||||
.all(|id| !workspace.is_dirty(&restarted, *id))
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_of_new_files_and_the_old_flag_name_survive_a_restart() {
|
||||
let (root, engine, mut workspace, ids) = setup();
|
||||
let folder = workspace.folder.clone().unwrap();
|
||||
let fresh = Uuid::new_v4();
|
||||
workspace.inherit(&[fresh.to_string()], &folder);
|
||||
workspace.links.get_mut(&ids[0]).unwrap().replaced = true;
|
||||
let json = serde_json::to_string(&workspace).unwrap();
|
||||
// links written by the previous version had `conflict`, which is the same thing
|
||||
let json = json.replace("\"replaced\"", "\"conflict\"");
|
||||
assert!(json.contains("\"conflict\":true"));
|
||||
let read: Workspace = serde_json::from_str(&json).unwrap();
|
||||
assert!(read.links[&ids[0]].replaced);
|
||||
assert_eq!(read.dirs.get(&fresh), Some(&folder));
|
||||
let _ = engine;
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
Reference in new issue
Block a user