diff --git a/tauri/Cargo.toml b/tauri/Cargo.toml index 89663d709..2c7f91147 100644 --- a/tauri/Cargo.toml +++ b/tauri/Cargo.toml @@ -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"] } diff --git a/tauri/build.rs b/tauri/build.rs index d860e1e6a..cb7cea911 100644 --- a/tauri/build.rs +++ b/tauri/build.rs @@ -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() } diff --git a/tauri/src/lib.rs b/tauri/src/lib.rs index 5e9a262c4..38791b5fe 100644 --- a/tauri/src/lib.rs +++ b/tauri/src/lib.rs @@ -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; +pub type Engine = gpx_engine::Engine; /// 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) -> Vec { +pub fn gpx_paths(arguments: impl IntoIterator) -> Vec { arguments .into_iter() .map(std::path::PathBuf::from) @@ -108,7 +108,7 @@ fn menu(app: &tauri::AppHandle) -> tauri::Result(builder: tauri::Builder) -> tauri::Builder { +pub fn register_commands(builder: tauri::Builder) -> tauri::Builder { builder.invoke_handler(tauri::generate_handler![ // selection selection::select, @@ -234,6 +234,3 @@ fn register_commands(builder: tauri::Builder) -> tauri::Bu system::reveal_path, ]) } - -#[cfg(test)] -mod tests; diff --git a/tauri/src/state.rs b/tauri/src/state.rs index 2ebed3734..fb5d7ea36 100644 --- a/tauri/src/state.rs +++ b/tauri/src/state.rs @@ -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) { self.core(move |core| core.open_external(&paths)); diff --git a/tauri/src/store.rs b/tauri/src/store.rs index 95b8a1ab8..a179df504 100644 --- a/tauri/src/store.rs +++ b/tauri/src/store.rs @@ -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(); - } -} diff --git a/tauri/src/workspace.rs b/tauri/src/workspace.rs index a62ecd3d6..3b6c896a9 100644 --- a/tauri/src/workspace.rs +++ b/tauri/src/workspace.rs @@ -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#"{name}{name}1020"# - ) - } - - /// The GPX files of a folder and of the folders in it. - pub(super) fn gpx_files(folder: &Path) -> Vec { - 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 { - 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 { - 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| -> 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) { - 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(); - } -} diff --git a/tauri/src/tests.rs b/tauri/tests/app/commands.rs similarity index 97% rename from tauri/src/tests.rs rename to tauri/tests/app/commands.rs index 3f8d17bb7..332ba6414 100644 --- a/tauri/src/tests.rs +++ b/tauri/tests/app/commands.rs @@ -10,10 +10,11 @@ struct App(WebviewWindow); impl App { fn new() -> Self { - let app = - crate::register_commands(mock_builder().manage(crate::state::AppState::temporary())) - .build(mock_context(noop_assets())) - .unwrap(); + 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()) .build() .unwrap(); @@ -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 diff --git a/tauri/tests/app/main.rs b/tauri/tests/app/main.rs new file mode 100644 index 000000000..943d233f8 --- /dev/null +++ b/tauri/tests/app/main.rs @@ -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") +} diff --git a/tauri/tests/app/store.rs b/tauri/tests/app/store.rs new file mode 100644 index 000000000..f903799f3 --- /dev/null +++ b/tauri/tests/app/store.rs @@ -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(); +} diff --git a/tauri/tests/app/workspace.rs b/tauri/tests/app/workspace.rs new file mode 100644 index 000000000..a7b288d2a --- /dev/null +++ b/tauri/tests/app/workspace.rs @@ -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#"{name}{name}1020"# + ) +} + +/// The GPX files of a folder and of the folders in it. +pub fn gpx_files(folder: &Path) -> Vec { + 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 { + 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 { + 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| -> 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) { + 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(); + } +} diff --git a/tauri/tests/windows-test.manifest b/tauri/tests/windows-test.manifest new file mode 100644 index 000000000..2d510ed31 --- /dev/null +++ b/tauri/tests/windows-test.manifest @@ -0,0 +1,14 @@ + + + + + + +