From 7f62d494e9cf6f3225eb809c06b8b991273b7e74 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Wed, 19 Mar 2025 18:07:14 +0000 Subject: [PATCH] Port unit tests from the C++ libloot implementation This gives function/line/region coverage of 88%/91%/84%. Most of the translation bugs fixed in earlier commits were found while writing these tests: the tests were kept separate to not drown out the fixes in the commit diffs. --- README.md | 4 +- src/archive/find.rs | 295 ++- src/archive/mod.rs | 61 + src/archive/parse.rs | 192 ++ src/database/conditions.rs | 75 + src/database/mod.rs | 917 ++++++++- src/game.rs | 1337 +++++++++++++- src/metadata/file.rs | 125 ++ src/metadata/group.rs | 42 + src/metadata/location.rs | 51 +- src/metadata/message.rs | 294 ++- src/metadata/metadata_document.rs | 589 +++++- src/metadata/mod.rs | 8 + src/metadata/plugin_cleaning_data.rs | 102 + src/metadata/plugin_metadata.rs | 485 +++++ src/metadata/tag.rs | 71 + src/plugin/mod.rs | 918 ++++++++- src/sorting/groups.rs | 210 +++ src/sorting/mod.rs | 75 + src/sorting/plugins.rs | 2568 +++++++++++++++++++++++++- src/tests.rs | 109 +- 21 files changed, 8363 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index 9fbfb6c2..f60c3622 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Currently complete: - [x] Archive-related functionality - [x] Metadata-related functionality (excluding writing YAML) - [x] Sorting functionality -- [ ] Unit tests +- [x] Unit tests - [ ] Integration tests - [ ] C++ FFI - [ ] Python FFI @@ -35,7 +35,7 @@ $env:LIBLOOT_REVISION = git rev-parse --short HEAD cargo build --release ``` -There aren't many tests, but those that exist can be run by first extracting the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there), then running: +The tests include a complete port of libloot's tests, and can be run by first extracting the [testing-plugins](https://github.com/Ortham/testing-plugins) archive to this readme's directory (so that there's a `testing-plugins` directory there), then running: ``` cargo test diff --git a/src/archive/find.rs b/src/archive/find.rs index ae9fb1ac..c54d7044 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -195,12 +195,295 @@ mod tests { use super::*; - #[test] - fn are_file_paths_equivalent_should_be_true_if_given_the_same_path_twice() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test"); - std::fs::write(&file_path, "").unwrap(); + mod find_associated_archives { + use std::path::absolute; - assert!(are_file_paths_equivalent(&file_path, &file_path)) + use rstest_reuse::apply; + use tempfile::TempDir; + + use super::*; + + use crate::tests::{ + BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP, + BLANK_MASTER_DEPENDENT_ESM, all_game_types, copy_file, source_plugins_path, + }; + + const NON_ASCII_ESP: &str = "non\u{00C1}scii.esp"; + + struct Fixture { + _temp_dir: TempDir, + cache: GameCache, + data_path: PathBuf, + } + + impl Fixture { + pub fn new(game_type: GameType) -> Self { + let tmp_dir = tempdir().unwrap(); + + let mut cache = GameCache::default(); + + let data_path = tmp_dir.path().to_path_buf(); + + match game_type { + GameType::TES3 | GameType::OpenMW => {} + GameType::FO4 | GameType::FO4VR | GameType::Starfield => { + let source = absolute("./testing-plugins/Fallout 4/Data").unwrap(); + copy_file(&source, &data_path, "Blank - Main.ba2"); + copy_file(&source, &data_path, "Blank - Textures.ba2"); + std::fs::copy( + source.join("Blank - Main.ba2"), + data_path.join("non\u{00C1}scii.ba2"), + ) + .unwrap(); + std::fs::copy( + source.join("Blank - Main.ba2"), + data_path.join("Blank - Different - Suffix.ba2"), + ) + .unwrap(); + + cache.set_archive_paths(vec![ + data_path.join("Blank - Main.ba2"), + data_path.join("Blank - Textures.ba2"), + data_path.join("non\u{00C1}scii.ba2"), + data_path.join("Blank - Different - Main.ba2"), + ]); + } + _ => { + let source = source_plugins_path(game_type); + copy_file(&source, &data_path, "Blank.bsa"); + std::fs::copy( + source.join("Blank.bsa"), + data_path.join("non\u{00C1}scii.bsa"), + ) + .unwrap(); + std::fs::copy( + source.join("Blank.bsa"), + data_path.join("Blank - Different - Main.bsa"), + ) + .unwrap(); + + cache.set_archive_paths(vec![ + data_path.join("Blank.bsa"), + data_path.join("non\u{00C1}scii.bsa"), + data_path.join("Blank - Different - Suffix.bsa"), + ]); + } + } + + Self { + _temp_dir: tmp_dir, + data_path, + cache, + } + } + } + + #[apply(all_game_types)] + fn should_return_empty_vec_if_no_matching_archives_are_found(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_MASTER_DEPENDENT_ESM), + ); + + assert!(archives.is_empty()); + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_exactly_matches_an_esm_file_basename_except_for_morrowind_and_oblivion( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_ESM), + ); + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::TES4 + ) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_exactly_matches_a_non_ascii_esp_file_basename_except_for_morrowind_and_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(NON_ASCII_ESP), + ); + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_starts_with_an_esp_file_basename_except_for_morrowind_and( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_ESP), + ); + + if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + assert!(archives.is_empty()); + } else { + assert!(!archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_starts_with_an_esm_file_basename_only_for_fallout( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_DIFFERENT_ESM), + ); + + if matches!( + game_type, + GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR + ) { + assert!(!archives.is_empty()); + } else { + assert!(archives.is_empty()); + } + } + + #[apply(all_game_types)] + fn should_find_an_archive_that_starts_with_an_esp_file_basename_only_for_oblivion_and_fallout( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let archives = find_associated_archives( + game_type, + &fixture.cache, + &fixture.data_path.join(BLANK_DIFFERENT_ESP), + ); + + if matches!( + game_type, + GameType::TES4 | GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR + ) { + assert!(!archives.is_empty()); + } else { + assert!(archives.is_empty()); + } + } + } + + mod are_file_paths_equivalent { + use super::*; + + #[test] + fn should_be_true_if_given_equal_paths_that_exist() { + let file_path = Path::new("README.md"); + + assert!(file_path.exists()); + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + fn should_be_true_if_given_equal_paths_that_do_not_exist() { + let file_path = Path::new("missing"); + + assert!(!file_path.exists()); + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + fn should_be_false_if_given_case_insensitively_equal_paths_that_do_not_exist() { + let file_path1 = Path::new("missing"); + let file_path2 = Path::new("MISSING"); + + assert!(!file_path1.exists()); + assert!(!file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + fn should_be_false_if_given_case_insensitively_unequal_paths_that_exist() { + let file_path1 = Path::new("README.md"); + let file_path2 = Path::new("LICENSE"); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(windows)] + fn should_be_true_if_given_case_insensitively_equal_paths_that_exist() { + let file_path1 = Path::new("README.md"); + let file_path2 = Path::new("readme.md"); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(windows)] + fn should_be_true_if_equal_paths_have_characters_that_are_unrepresentable_in_the_system_multi_byte_code_page() + { + let file_path = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"); + + assert!(are_file_paths_equivalent(file_path, file_path)); + } + + #[test] + #[cfg(windows)] + fn should_be_false_if_case_insensitively_equal_paths_have_characters_that_are_unrepresentable_in_the_system_multi_byte_code_page_and_do_not_exist() + { + let file_path1 = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00E3}\u{00CE}.txt"); + let file_path2 = + Path::new("\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"); + + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } + + #[test] + #[cfg(not(windows))] + fn should_be_false_if_given_case_insensitively_equal_paths_that_exist() { + let tmp_dir = tempdir(); + let file_path1 = tmp_dir.path().join("test"); + let file_path2 = tmp_dir.path().join("TEST"); + + std::fs::File::create(&file_path1).unwrap(); + std::fs::File::create(&file_path2).unwrap(); + + assert!(file_path1.exists()); + assert!(file_path2.exists()); + assert!(!are_file_paths_equivalent(file_path1, file_path2)); + } } } diff --git a/src/archive/mod.rs b/src/archive/mod.rs index 16582db8..950fbeb9 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -4,5 +4,66 @@ mod error; mod find; mod parse; +use std::collections::{BTreeMap, BTreeSet}; + pub use find::find_associated_archives; pub use parse::assets_in_archives; + +pub fn do_assets_overlap( + assets: &BTreeMap>, + other_assets: &BTreeMap>, +) -> bool { + let mut assets_iter = assets.iter(); + let mut other_assets_iter = other_assets.iter(); + + let mut assets = assets_iter.next(); + let mut other_assets = other_assets_iter.next(); + while let (Some((folder, files)), Some((other_folder, other_files))) = (assets, other_assets) { + if folder < other_folder { + assets = assets_iter.next(); + } else if folder > other_folder { + other_assets = other_assets_iter.next(); + } else if files.intersection(other_files).next().is_some() { + return true; + } else { + // The folder hashes are equal but they don't contain any of the same + // file hashes, move on to the next folder. It doesn't matter which + // iterator gets incremented. + assets = assets_iter.next(); + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + mod do_assets_overlap { + use std::path::PathBuf; + + use super::*; + + #[test] + fn should_return_true_if_the_same_file_exists_in_the_same_folder() { + let path = PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets = assets_in_archives(&[path]); + + assert!(do_assets_overlap(&assets, &assets)); + } + + #[test] + fn should_return_false_if_the_same_file_exists_in_different_folders() { + let path = PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets1 = assets_in_archives(&[path]); + + let path = PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"); + let assets2 = assets_in_archives(&[path]); + + assert_eq!(assets1.get(&0), assets2.get(&0x2E01002E)); + + assert!(!do_assets_overlap(&assets1, &assets2)) + } + } +} diff --git a/src/archive/parse.rs b/src/archive/parse.rs index 2e3ca660..6487b2dc 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -108,3 +108,195 @@ pub(super) fn to_u64(bytes: &[u8]) -> u64 { pub(super) fn to_usize(size: u32) -> usize { usize::try_from(size).expect("usize can hold a u32") } + +#[cfg(test)] +mod tests { + use super::*; + + mod get_assets_in_archive { + use std::{ + hash::{DefaultHasher, Hash, Hasher}, + io::SeekFrom, + }; + + use rstest::rstest; + use tempfile::tempdir; + + use super::*; + + fn hash(value: T) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn should_error_if_file_cannot_be_opened() { + let path = Path::new("./invalid.bsa"); + assert!(get_assets_in_archive(path).is_err()); + } + + #[test] + fn should_support_v103_bsas() { + let path = Path::new("./testing-plugins/Oblivion/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = 0; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!( + 0x4670B6836C077365, + *assets.get(&expected_key).unwrap().first().unwrap() + ); + } + + #[test] + fn should_support_v104_bsas() { + let path = Path::new("./testing-plugins/Skyrim/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = 0x2E01002E; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!( + 0x4670B6836C077365, + *assets.get(&expected_key).unwrap().first().unwrap() + ); + } + + #[test] + fn should_support_v105_bsas() { + let path = Path::new("./testing-plugins/SkyrimSE/Data/Blank.bsa"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = 0xB68102C964176E73; + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + assert_eq!(expected_key, *assets.first_key_value().unwrap().0); + assert_eq!(1, assets.get(&expected_key).unwrap().len()); + assert_eq!( + 0x4670B6836C077365, + *assets.get(&expected_key).unwrap().first().unwrap() + ); + } + + #[test] + fn should_support_general_ba2s() { + let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Main.ba2"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); + let expected_file_hash = hash("license.txt".as_bytes()); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(expected_key, *key); + assert_eq!(1, value.len()); + assert_eq!(expected_file_hash, *value.first().unwrap()); + } + + #[test] + fn should_support_texture_ba2s() { + let path = Path::new("./testing-plugins/Fallout 4/Data/Blank - Textures.ba2"); + let assets = get_assets_in_archive(path).unwrap(); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + let expected_key = hash("dev\\git\\testing-plugins".as_bytes()); + let expected_file_hash = hash("blank.dds".as_bytes()); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(expected_key, *key); + assert_eq!(1, value.len()); + assert_eq!(expected_file_hash, *value.first().unwrap()); + } + + #[rstest] + fn should_support_ba2_versions(#[values(1, 2, 3, 7, 8)] version: u32) { + use std::io::{Seek, Write}; + + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("test.ba2"); + + std::fs::copy("./testing-plugins/Fallout 4/Data/Blank - Main.ba2", &path).unwrap(); + + { + let mut file = File::options().write(true).open(&path).unwrap(); + file.seek(SeekFrom::Start(4)).unwrap(); + file.write_all(&version.to_le_bytes()).unwrap(); + } + + let assets = get_assets_in_archive(&path).unwrap(); + assert!(!assets.is_empty()); + } + } + + mod assets_in_archives { + use super::*; + + #[test] + fn should_skip_files_that_cannot_be_read() { + let paths = [ + PathBuf::from("invalid.bsa"), + PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"), + ]; + + let assets = assets_in_archives(&paths); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + assert_eq!(1, assets.len()); + assert_eq!(1, files_count); + + let (key, value) = assets.first_key_value().unwrap(); + assert_eq!(0x2E01002E, *key); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + } + + #[test] + fn should_combine_assets_from_each_loaded_archive() { + let paths = [ + PathBuf::from("./testing-plugins/Oblivion/Data/Blank.bsa"), + PathBuf::from("./testing-plugins/Skyrim/Data/Blank.bsa"), + PathBuf::from("./testing-plugins/SkyrimSE/Data/Blank.bsa"), + ]; + + let assets = assets_in_archives(&paths); + + let files_count: usize = assets.values().map(|v| v.len()).sum(); + + assert_eq!(3, assets.len()); + assert_eq!(3, files_count); + + let value = assets.get(&0).unwrap(); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + + let value = assets.get(&0x2E01002E).unwrap(); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + + let value = assets.get(&0xB68102C964176E73).unwrap(); + assert_eq!(1, value.len()); + assert_eq!(0x4670B6836C077365, *value.first().unwrap()); + } + } +} diff --git a/src/database/conditions.rs b/src/database/conditions.rs index 22640623..9e777998 100644 --- a/src/database/conditions.rs +++ b/src/database/conditions.rs @@ -106,3 +106,78 @@ fn filter_cleaning_data_on_conditions( }) .collect::, _>>() } + +#[cfg(test)] +mod tests { + use super::*; + + mod evaluate_all_conditions { + use crate::{ + metadata::{Message, MessageType, Tag, TagSuggestion}, + tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, source_plugins_path}, + }; + + use super::*; + + #[test] + fn should_evaluate_all_conditions_on_metadata_in_plugin_metadata_object() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_group("group1".into()); + + let condition = "file(\"missing.esp\")".to_string(); + let file1 = File::new(BLANK_ESP.into()); + let file2 = File::new(BLANK_DIFFERENT_ESM.into()).with_condition(condition.clone()); + let files = vec![file1.clone(), file2]; + plugin.set_load_after_files(files.clone()); + plugin.set_requirements(files.clone()); + plugin.set_incompatibilities(files.clone()); + + let message1 = Message::new(MessageType::Say, "content1".into()); + let message2 = + Message::new(MessageType::Say, "content2".into()).with_condition(condition.clone()); + plugin.set_messages(vec![message1.clone(), message2]); + + let tag1 = Tag::new("Delev".into(), TagSuggestion::Addition); + let tag2 = + Tag::new("Relev".into(), TagSuggestion::Addition).with_condition(condition.clone()); + plugin.set_tags(vec![tag1.clone(), tag2]); + + let info1 = PluginCleaningData::new(0x374E2A6F, "utility1".into()); + let info2 = PluginCleaningData::new(0xDEADBEEF, "utility2".into()); + plugin.set_dirty_info(vec![info1.clone(), info2.clone()]); + plugin.set_clean_info(vec![info1.clone(), info2.clone()]); + + let state = loot_condition_interpreter::State::new( + loot_condition_interpreter::GameType::Oblivion, + source_plugins_path(crate::GameType::TES4), + ); + let result = evaluate_all_conditions(plugin, &state).unwrap().unwrap(); + + let expected_files = &[file1]; + let expected_info = &[info1]; + assert_eq!("group1", result.group().unwrap()); + assert_eq!(expected_files, result.load_after_files()); + assert_eq!(expected_files, result.requirements()); + assert_eq!(expected_files, result.incompatibilities()); + assert_eq!(&[message1], result.messages()); + assert_eq!(&[tag1], result.tags()); + assert_eq!(expected_info, result.dirty_info()); + assert_eq!(expected_info, result.clean_info()); + } + + #[test] + fn should_return_none_if_evaluated_plugin_metadata_has_name_only() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file = File::new(BLANK_DIFFERENT_ESM.into()) + .with_condition("file(\"missing.esp\")".into()); + plugin.set_load_after_files(vec![file]); + + let state = loot_condition_interpreter::State::new( + loot_condition_interpreter::GameType::Oblivion, + source_plugins_path(crate::GameType::TES4), + ); + assert!(evaluate_all_conditions(plugin, &state).unwrap().is_none()); + } + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index ebf7aff9..b1fdac90 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -106,7 +106,7 @@ impl Database { let mut doc = MetadataDocument::default(); - for plugin in self.masterlist.plugins() { + for plugin in self.masterlist.plugins_iter() { let mut minimal_plugin = PluginMetadata::new(plugin.name()) .expect("Regex plugin name from existing PluginMetadata object is valid"); minimal_plugin.set_tags(plugin.tags().to_vec()); @@ -333,3 +333,918 @@ fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec { groups } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{ + EdgeType, GameType, + metadata::{File, MessageType}, + tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM}, + }; + + use super::*; + + struct Fixture { + inner: crate::tests::Fixture, + prelude_path: PathBuf, + metadata_path: PathBuf, + } + + impl Fixture { + fn new(game_type: GameType) -> Self { + let inner = crate::tests::Fixture::new(game_type); + + let prelude = "- &preludeBashTag Actors.ACBS"; + let prelude_path = inner.local_path.join("prelude.yaml"); + std::fs::write(&prelude_path, prelude).unwrap(); + + let metadata = " +prelude: + - &preludeBashTag C.Climate +bash_tags: + - *preludeBashTag +globals: + - type: say + content: 'A general message' + condition: 'file(\"missing.esp\")' +groups: + - name: group1 + - name: group2 + after: + - group1 +plugins: + - name: Blank.esm + after: + - Oblivion.esm + msg: + - type: say + content: 'A note message' + condition: 'file(\"missing.esp\")' + tag: + - Actors.ACBS + - Actors.AIData + - '-C.Water' + - name: Blank - Different.esm + after: + - Blank - Master Dependent.esm + msg: + - type: warn + content: 'A warning message' + dirty: + - crc: 0x7d22f9df + util: TES4Edit + udr: 4 + - name: Blank - Different.esp + after: + - Blank - Plugin Dependent.esp + msg: + - type: error + content: 'An error message' + - name: Blank.esp + after: + - Blank - Different Master Dependent.esp + - name: Blank - Different Master Dependent.esp + after: + - Blank - Master Dependent.esp + msg: + - type: say + content: 'A note message' + - type: warn + content: 'A warning message' + - type: error + content: 'An error message'"; + let metadata_path = inner.local_path.join("metadata.yaml"); + std::fs::write(&metadata_path, metadata).unwrap(); + + Self { + inner, + prelude_path, + metadata_path, + } + } + + fn database(&self) -> Database { + Database::new(loot_condition_interpreter::State::new( + self.inner.game_type.into(), + self.inner.data_path(), + )) + } + } + + #[test] + fn load_masterlist_should_succeed_if_given_a_valid_path() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&["C.Climate"], database.known_bash_tags().as_slice()); + } + + #[test] + fn load_masterlist_with_prelude_should_succeed_if_given_valid_paths() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database + .load_masterlist_with_prelude(&fixture.metadata_path, &fixture.prelude_path) + .unwrap(); + + assert_eq!(&["Actors.ACBS"], database.known_bash_tags().as_slice()); + } + + #[test] + fn load_userlist_should_succeed_if_given_a_valid_path() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_userlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&["C.Climate"], database.known_bash_tags().as_slice()); + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.user_groups() + ); + } + + mod write_user_metadata { + use super::*; + + #[test] + fn should_write_only_user_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + database.set_user_groups(vec![Group::new("group3".into())]); + + let output_path = fixture.inner.local_path.join("userlist.yaml"); + database + .write_user_metadata(&output_path, false) + .unwrap(); + + let content = std::fs::read_to_string(output_path).unwrap(); + + assert_eq!("groups:\n - name: 'default'\n - name: 'group3'", content); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, false) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_error_if_the_parent_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("missing/userlist.yaml"); + + assert!( + database + .write_user_metadata(&output_path, false) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_is_read_only() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + let mut permissions = output_path.metadata().unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&output_path, permissions).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, true) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("userlist.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_user_metadata(&output_path, false) + .is_err() + ); + } + } + + mod write_minimal_list { + use crate::tests::{BLANK_DIFFERENT_ESM, BLANK_ESM}; + + use super::*; + + #[test] + fn should_only_write_plugin_bash_tags_and_dirty_info() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_ok() + ); + + let content = std::fs::read_to_string(output_path).unwrap(); + + // Plugin entries are unordered. + let expected_content = if content.find(BLANK_DIFFERENT_ESM) < content.find(BLANK_ESM) { + "plugins: + - name: 'Blank - Different.esm' + dirty: + - crc: 0x7D22F9DF + util: 'TES4Edit' + udr: 4 + - name: 'Blank.esm' + tag: + - Actors.ACBS + - Actors.AIData + - -C.Water" + } else { + "plugins: + - name: 'Blank.esm' + tag: + - Actors.ACBS + - Actors.AIData + - -C.Water + - name: 'Blank - Different.esm' + dirty: + - crc: 0x7D22F9DF + util: 'TES4Edit' + udr: 4" + }; + + assert_eq!(expected_content, content); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_does_not_exist_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_succeed_if_the_path_exists_and_truncation_is_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, true) + .is_ok() + ); + } + + #[test] + fn should_error_if_the_parent_path_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("missing/minimal.yaml"); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_is_read_only() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + let mut permissions = output_path.metadata().unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&output_path, permissions).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, true) + .is_err() + ); + } + + #[test] + fn should_error_if_the_path_exists_and_truncation_is_not_allowed() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + let output_path = fixture.inner.local_path.join("minimal.yaml"); + + std::fs::File::create(&output_path).unwrap(); + + assert!( + database + .write_minimal_list(&output_path, false) + .is_err() + ); + } + } + + #[test] + fn known_bash_tags_should_append_userlist_tags_to_masterlist_tags() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write(&userlist_path, "bash_tags: [Relev, Delev]").unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + vec!["C.Climate", "Relev", "Delev"], + database.known_bash_tags() + ); + } + + mod general_messages { + use super::*; + + #[test] + fn should_append_userlist_messages_to_masterlist_messages() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "globals: [{type: say, content: 'A user message'}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Message::new(MessageType::Say, "A general message".into()) + .with_condition("file(\"missing.esp\")".into()), + Message::new(MessageType::Say, "A user message".into()) + ], + database.general_messages(false).unwrap().as_slice() + ); + } + + #[test] + fn should_filter_out_messages_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "globals: [{type: say, content: 'A user message'}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[Message::new(MessageType::Say, "A user message".into())], + database.general_messages(true).unwrap().as_slice() + ); + } + } + + mod groups { + use super::*; + + #[test] + fn should_return_default_group_before_metadata_has_been_loaded() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert_eq!(&[Group::default(),], database.groups(true).as_slice()); + } + + #[test] + fn should_not_include_user_groups_if_param_is_false() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(false).as_slice() + ); + } + + #[test] + fn should_merge_masterlist_and_userlist_groups() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()) + .with_after_groups(vec!["group1".into(), "default".into()]), + Group::new("group3".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(true).as_slice() + ); + } + } + + #[test] + fn user_groups_should_not_include_masterlist_groups() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + assert_eq!(&[Group::default(),], database.user_groups()); + } + + #[test] + fn set_user_groups_should_replace_existing_user_groups() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let userlist_path = fixture.inner.local_path.join("userlist.yaml"); + std::fs::write( + &userlist_path, + "groups: [{name: group2, after: [default]}, {name: group3, after: [group1]}]", + ) + .unwrap(); + + database.load_userlist(&userlist_path).unwrap(); + + database.set_user_groups(vec![Group::new("group4".into())]); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(false).as_slice() + ); + + assert_eq!( + &[Group::default(), Group::new("group4".into())], + database.user_groups() + ); + } + + #[test] + fn groups_path_should_find_path_using_masterlist_and_user_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + database.set_user_groups(vec![ + Group::new("group3".into()).with_after_groups(vec!["group2".into()]), + ]); + + let path = database.groups_path("group1", "group3").unwrap(); + + assert_eq!( + vec![ + Vertex::new("group1".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("group2".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("group3".into()), + ], + path + ); + } + + mod plugin_metadata { + use crate::tests::BLANK_ESM; + + use super::*; + + #[test] + fn should_return_none_if_plugin_has_no_metadata_set() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_return_none_if_plugin_metadata_has_only_name() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_prefer_user_metadata_when_merging_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[ + File::new(BLANK_DIFFERENT_ESM.into()), + File::new("Oblivion.esm".into()) + ], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_return_only_masterlist_metadata_if_include_user_metadata_is_false() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, false, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_messages(vec![ + Message::new(MessageType::Say, "content".into()) + .with_condition("file(\"missing.esp\")".into()), + ]); + + database.set_plugin_user_metadata(plugin); + + assert!( + database + .plugin_metadata(BLANK_ESM, true, true) + .unwrap() + .unwrap() + .messages() + .is_empty() + ); + } + } + + mod plugin_user_metadata { + use super::*; + + #[test] + fn should_return_none_if_plugin_has_no_user_metadata_set() { + let fixture = Fixture::new(GameType::TES4); + let database = fixture.database(); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_return_none_if_plugin_user_metadata_has_only_name() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.set_plugin_user_metadata(PluginMetadata::new(BLANK_ESM).unwrap()); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .is_none() + ) + } + + #[test] + fn should_return_only_user_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESM.into())], + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_filter_out_metadata_with_false_conditions_when_evaluating_conditions() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![ + File::new(BLANK_DIFFERENT_ESM.into()) + .with_condition("file(\"missing.esp\")".into()), + ]); + + database.set_plugin_user_metadata(plugin); + + assert!( + database + .plugin_user_metadata(BLANK_ESM, true) + .unwrap() + .is_none() + ); + } + } + + mod set_plugin_user_metadata { + use super::*; + + #[test] + fn should_replace_existing_user_metadata_for_the_plugin() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin.clone()); + + plugin.set_load_after_files(vec![File::new(BLANK_MASTER_DEPENDENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[File::new(BLANK_MASTER_DEPENDENT_ESM.into())], + database + .plugin_user_metadata(BLANK_ESM, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn should_not_modify_masterlist_metadata_for_the_plugin() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + database.set_plugin_user_metadata(plugin); + + assert_eq!( + &[ + File::new(BLANK_DIFFERENT_ESM.into()), + File::new("Oblivion.esm".into()), + ], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + } + + #[test] + fn discard_plugin_user_metadata_should_discard_only_user_metadata_for_only_the_given_plugin() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + let mut plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + plugin2.set_load_after_files(vec![File::new(BLANK_ESM.into())]); + + database.set_plugin_user_metadata(plugin1); + database.set_plugin_user_metadata(plugin2); + + database.discard_plugin_user_metadata(BLANK_ESM); + + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + assert_eq!( + &[ + File::new(BLANK_ESM.into()), + File::new(BLANK_MASTER_DEPENDENT_ESM.into()), + ], + database + .plugin_metadata(BLANK_DIFFERENT_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } + + #[test] + fn discard_all_user_metadata_should_not_remove_masterlist_metadata() { + let fixture = Fixture::new(GameType::TES4); + let mut database = fixture.database(); + + database.load_masterlist(&fixture.metadata_path).unwrap(); + + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + let mut plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + plugin2.set_load_after_files(vec![File::new(BLANK_ESM.into())]); + + database.set_user_groups(vec![Group::new("group4".into())]); + database.set_plugin_user_metadata(plugin1); + database.set_plugin_user_metadata(plugin2); + + database.discard_all_user_metadata(); + + assert_eq!( + &[ + Group::default(), + Group::new("group1".into()), + Group::new("group2".into()).with_after_groups(vec!["group1".into()]) + ], + database.groups(true).as_slice() + ); + assert_eq!( + &[File::new("Oblivion.esm".into())], + database + .plugin_metadata(BLANK_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + assert_eq!( + &[File::new(BLANK_MASTER_DEPENDENT_ESM.into()),], + database + .plugin_metadata(BLANK_DIFFERENT_ESM, true, false) + .unwrap() + .unwrap() + .load_after_files() + ); + } +} diff --git a/src/game.rs b/src/game.rs index 60eb671d..9ae6dd85 100644 --- a/src/game.rs +++ b/src/game.rs @@ -739,7 +739,7 @@ pub(crate) struct GameCache { } impl GameCache { - fn set_archive_paths(&mut self, archive_paths: Vec) { + pub fn set_archive_paths(&mut self, archive_paths: Vec) { self.archive_paths.clear(); self.archive_paths.extend(archive_paths); } @@ -772,55 +772,1320 @@ impl GameCache { mod tests { use super::*; - use rstest::rstest; - use rstest_reuse::{apply, template}; + use rstest_reuse::apply; - use crate::tests::Fixture; + use crate::tests::{Fixture, all_game_types}; - #[template] - #[rstest] - fn all_game_types( - #[values( - GameType::TES4, - GameType::TES5, - GameType::FO3, - GameType::FONV, - GameType::FO4, - GameType::TES5SE, - GameType::FO4VR, - GameType::TES5VR, - GameType::TES3, - GameType::Starfield, - GameType::OpenMW - )] - game_type: GameType, - ) { - } + mod game { + use std::path::Component; + + use crate::tests::BLANK_ESM; - mod new { use super::*; - #[apply(all_game_types)] - fn should_succeed_if_given_valid_game_path(game_type: GameType) { - let fixture = Fixture::new(game_type); + #[cfg(windows)] + fn symlink_dir(original: &Path, link: &Path) { + std::os::windows::fs::symlink_dir(original, link).unwrap(); + } - let game = Game::new(fixture.game_type, &fixture.game_path); + #[cfg(unix)] + fn symlink_dir(original: &Path, link: &Path) { + std::os::unix::fs::symlink(original, link).unwrap(); + } - assert!(game.is_ok()); + #[cfg(windows)] + fn junction_link(original: &Path, link: &Path) { + use std::ffi::{OsStr, OsString}; + + // The paths may contain forward slashes, which cmd doesn't accept, + // so replace them. + let original = OsString::from(original.to_str().unwrap().replace("/", "\\")); + let link = OsString::from(link.to_str().unwrap().replace("/", "\\")); + + let status = std::process::Command::new("cmd") + .args([ + OsStr::new("/C"), + OsStr::new("mklink"), + OsStr::new("/J"), + link.as_os_str(), + original.as_os_str(), + ]) + .status() + .unwrap(); + + assert!(status.success()); + } + + fn make_relative(path: &Path) -> PathBuf { + if path.is_relative() { + return path.to_path_buf(); + } + + let base = std::env::current_dir().unwrap(); + assert!(base.is_absolute()); + + let mut path_iter = path.components(); + let mut base_iter = base.components(); + + let mut relative_components = Vec::new(); + loop { + match (path_iter.next(), base_iter.next()) { + (None, None) => break, + (None, _) => relative_components.push(Component::ParentDir), + (Some(p), None) => { + relative_components.push(p); + relative_components.extend(path_iter); + break; + } + (Some(p), Some(b)) => { + if relative_components.is_empty() && p == b { + continue; + } + + relative_components.push(Component::ParentDir); + for _ in base_iter { + relative_components.push(Component::ParentDir); + } + + relative_components.push(p); + relative_components.extend(path_iter); + break; + } + } + } + + relative_components + .into_iter() + .map(|c| c.as_os_str()) + .collect() + } + + mod new { + use super::*; + + #[cfg(windows)] + #[apply(all_game_types)] + fn should_succeed_if_given_valid_game_path(game_type: GameType) { + let fixture = Fixture::new(game_type); + + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } + + #[cfg(not(windows))] + #[apply(all_game_types)] + fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { + if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } else { + assert!(Game::new(fixture.game_type, &fixture.game_path).is_err()); + } + } + + #[test] + fn should_succeed_if_given_a_relative_game_path() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = make_relative(&fixture.game_path); + assert!(game_path.is_relative()); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[test] + fn should_succeed_if_given_an_absolute_game_path() { + let fixture = Fixture::new(GameType::TES4); + + assert!(fixture.game_path.is_absolute()); + assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); + } + + #[test] + fn should_succeed_if_given_a_symlink_path() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("symlink"); + symlink_dir(&fixture.game_path, &game_path); + assert!(game_path.is_symlink()); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[cfg(windows)] + #[test] + fn should_succeed_if_given_a_junction_link_path() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("junction"); + junction_link(&fixture.game_path, &game_path); + + assert!(Game::new(fixture.game_type, &game_path).is_ok()); + } + + #[test] + fn should_error_if_given_a_game_path_that_does_not_exist() { + let game_path = Path::new("missing"); + match Game::new(GameType::TES4, game_path) { + Err(GameHandleCreationError::NotADirectory(p)) => { + assert_eq!(game_path, p) + } + _ => panic!("Expected a not-a-directory error"), + } + } + } + + mod with_local_path { + use super::*; + + #[apply(all_game_types)] + fn should_succeed_if_given_valid_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_relative_paths() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = make_relative(&fixture.game_path); + assert!(game_path.is_relative()); + + let local_path = make_relative(&fixture.local_path); + assert!(local_path.is_relative()); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_absolute_paths() { + let fixture = Fixture::new(GameType::TES4); + + assert!(fixture.game_path.is_absolute()); + assert!(fixture.local_path.is_absolute()); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ); + + assert!(game.is_ok()); + } + + #[test] + fn should_succeed_if_given_symlink_paths() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("symlink"); + symlink_dir(&fixture.game_path, &game_path); + assert!(game_path.is_symlink()); + + let local_path = fixture.local_path.with_extension("symlink"); + symlink_dir(&fixture.local_path, &local_path); + assert!(local_path.is_symlink()); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[cfg(windows)] + #[test] + fn should_succeed_if_given_junction_link_paths() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = fixture.game_path.with_extension("junction"); + junction_link(&fixture.game_path, &game_path); + + let local_path = fixture.local_path.with_extension("junction"); + junction_link(&fixture.local_path, &local_path); + + let game = Game::with_local_path(fixture.game_type, &game_path, &local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_error_if_given_a_game_path_that_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + + let game_path = Path::new("missing"); + let game = Game::with_local_path(fixture.game_type, game_path, &fixture.local_path); + + match game { + Err(GameHandleCreationError::NotADirectory(p)) => { + assert_eq!(game_path, p) + } + _ => panic!("Expected a not-a-directory error"), + } + } + + #[test] + fn should_succeed_if_given_a_local_path_that_does_not_exist() { + let fixture = Fixture::new(GameType::TES4); + + let local_path = Path::new("missing"); + let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); + + assert!(game.is_ok()); + } + + #[test] + fn should_error_if_given_a_local_path_that_is_not_a_directory() { + let fixture = Fixture::new(GameType::TES4); + + let local_path = Path::new("README.md"); + assert!(local_path.exists()); + + let game = Game::with_local_path(fixture.game_type, &fixture.game_path, local_path); + + match game { + Err(GameHandleCreationError::NotADirectory(p)) => { + assert_eq!(local_path, p) + } + _ => panic!("Expected a not-a-directory error"), + } + } + + #[apply(all_game_types)] + fn should_set_default_additional_data_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + match game_type { + GameType::FO4 => { + std::fs::File::create(fixture.game_path.join("appxmanifest.xml")).unwrap(); + } + GameType::OpenMW => { + let contents = format!( + "data-local=\"{}\"\nconfig=\"{}\"", + fixture.local_path.join("data").display(), + fixture.local_path.display() + ); + std::fs::write(fixture.game_path.join("openmw.cfg"), contents).unwrap(); + } + _ => {} + } + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + match game_type { + GameType::FO4 => { + let base_path = fixture.game_path.join("../.."); + assert_eq!( + &[ + base_path + .join("Fallout 4- Automatron (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Nuka-World (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Wasteland Workshop (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- High Resolution Texture Pack") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Vault-Tec Workshop (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Far Harbor (PC)") + .join("Content") + .join("Data"), + base_path + .join("Fallout 4- Contraptions Workshop (PC)") + .join("Content") + .join("Data") + ], + game.additional_data_paths() + ); + } + GameType::Starfield => { + assert_eq!(1, game.additional_data_paths().len()); + + let expected_suffix = Path::new("Documents") + .join("My Games") + .join("Starfield") + .join("Data"); + + assert!(game.additional_data_paths()[0].ends_with(expected_suffix)); + } + GameType::OpenMW => { + assert_eq!( + &[fixture.local_path.join("data")], + game.additional_data_paths() + ); + } + _ => assert!(game.additional_data_paths().is_empty()), + } + } + + mod set_additional_data_paths { + use std::time::{Duration, SystemTime}; + + use crate::{ + metadata::{File, PluginMetadata}, + tests::{BLANK_ESM, BLANK_ESP}, + }; + + use super::*; + + #[test] + fn should_clear_the_condition_cache() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let mut metadata = PluginMetadata::new(BLANK_ESM).unwrap(); + metadata.set_load_after_files(vec![ + File::new("plugin.esp".into()) + .with_condition("file(\"plugin.esp\")".into()), + ]); + game.database() + .write() + .unwrap() + .set_plugin_user_metadata(metadata); + + let evaluated_metadata = game + .database() + .read() + .unwrap() + .plugin_user_metadata(BLANK_ESM, true) + .unwrap(); + assert!(evaluated_metadata.is_none()); + + std::fs::File::create(fixture.data_path().join("plugin.esp")).unwrap(); + + game.set_additional_data_paths(&[Path::new("")]).unwrap(); + + let evaluated_metadata = game + .database() + .read() + .unwrap() + .plugin_user_metadata(BLANK_ESM, true) + .unwrap() + .unwrap(); + assert!(!evaluated_metadata.load_after_files().is_empty()); + } + + #[test] + fn should_update_where_load_order_plugins_are_found() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + let mut load_order: Vec<_> = + game.load_order().iter().map(|s| s.to_string()).collect(); + + let filename = "plugin.esp"; + let data_file_path = fixture + .game_path + .parent() + .unwrap() + .join("Data") + .join(filename); + + std::fs::create_dir_all(data_file_path.parent().unwrap()).unwrap(); + std::fs::copy(fixture.data_path().join(BLANK_ESP), &data_file_path).unwrap(); + + std::fs::File::options() + .write(true) + .open(&data_file_path) + .unwrap() + .set_modified(SystemTime::now() + Duration::from_secs(3600)) + .unwrap(); + + game.set_additional_data_paths(&[data_file_path.parent().unwrap()]) + .unwrap(); + game.load_current_load_order_state().unwrap(); + + load_order.push(filename.to_string()); + + assert_eq!(load_order, game.load_order()); + } + } + } + + mod is_valid_plugin { + use super::*; + + use crate::tests::{ + BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM, NON_ASCII_ESM, NON_PLUGIN_FILE, + }; + + #[apply(all_game_types)] + fn should_return_true_for_a_valid_non_ascii_plugin(game_type: GameType) { + let fixture = Fixture::new(game_type); + + std::fs::copy( + fixture.data_path().join(BLANK_ESM), + fixture.data_path().join(NON_ASCII_ESM), + ) + .unwrap(); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.is_valid_plugin(Path::new(NON_ASCII_ESM))); + } + + #[apply(all_game_types)] + fn should_return_true_for_an_omwscripts_plugin(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let plugin = fixture.data_path().join("empty.omwscripts"); + let _ = std::fs::File::create(&plugin).unwrap(); + + if game_type == GameType::OpenMW { + assert!(game.is_valid_plugin(&plugin)); + } else { + assert!(!game.is_valid_plugin(&plugin)); + } + } + + #[apply(all_game_types)] + fn should_return_false_for_a_non_plugin_file(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(!game.is_valid_plugin(Path::new(NON_PLUGIN_FILE))); + } + + #[apply(all_game_types)] + fn should_return_false_for_an_empty_file(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let empty_file_path = fixture.data_path().join("empty.esp"); + let _ = std::fs::File::create(&empty_file_path).unwrap(); + + assert!(!game.is_valid_plugin(&empty_file_path)); + } + + #[apply(all_game_types)] + fn should_try_ghosted_path_if_given_plugin_does_not_exist_unless_game_is_openmw( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_MASTER_DEPENDENT_ESM); + + if game_type == GameType::OpenMW { + std::fs::rename( + &path, + path.with_file_name(format!("{}.ghost", BLANK_MASTER_DEPENDENT_ESM)), + ) + .unwrap(); + + assert!(!game.is_valid_plugin(&path)); + } else { + assert!(!path.exists()); + + assert!(game.is_valid_plugin(&path)); + } + } + + #[test] + fn should_resolve_relative_paths_relative_to_the_data_path() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = Path::new("..") + .join(fixture.data_path().file_name().unwrap()) + .join(BLANK_ESM); + + assert!(game.is_valid_plugin(&path)); + } + + #[test] + fn should_use_absolute_paths_as_given() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_ESM); + + assert!(game.is_valid_plugin(&path)); + } + } + + mod load_plugin_headers { + use crate::tests::{BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, NON_PLUGIN_FILE}; + + use super::*; + + #[apply(all_game_types)] + fn should_load_the_headers_of_the_given_plugins(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.plugin(BLANK_ESM).is_none()); + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_none()); + + game.load_plugin_headers(&[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]) + .unwrap(); + + let plugin = game.plugin(BLANK_ESM).unwrap(); + assert_eq!("5.0", plugin.version().unwrap()); + assert!(plugin.crc().is_none()); + + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_not_modify_loaded_plugins_storage_if_given_a_non_plugin() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + assert!( + game.load_plugin_headers(&[Path::new(NON_PLUGIN_FILE)]) + .is_err() + ); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(NON_PLUGIN_FILE).is_none()); + } + + #[test] + fn should_not_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + game.load_plugin_headers(&[Path::new(BLANK_ESP)]).unwrap(); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_replace_an_existing_cache_entry_for_the_same_plugin() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + let plugin1: *const str = game.plugin(BLANK_ESM).unwrap().name(); + let plugin2: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_eq!(plugin1, plugin2); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + + let plugin3: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_ne!(plugin2, plugin3); + } + } + + mod load_plugins { + use std::error::Error; + + use crate::tests::{ + BLANK_DIFFERENT_ESM, BLANK_ESM, BLANK_ESP, BLANK_FULL_ESM, + BLANK_MASTER_DEPENDENT_ESM, + }; + + use super::*; + + #[apply(all_game_types)] + fn should_fully_load_the_given_plugins(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.plugin(BLANK_ESM).is_none()); + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_none()); + + game.load_plugins(&[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]) + .unwrap(); + + let plugin = game.plugin(BLANK_ESM).unwrap(); + assert_eq!("5.0", plugin.version().unwrap()); + assert!(plugin.crc().is_some()); + + assert!(game.plugin(BLANK_DIFFERENT_ESM).is_none()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_not_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + assert!(game.plugin(BLANK_ESM).is_some()); + + game.load_plugin_headers(&[Path::new(BLANK_ESP)]).unwrap(); + + assert!(game.plugin(BLANK_ESM).is_some()); + assert!(game.plugin(BLANK_ESP).is_some()); + } + + #[test] + fn should_replace_an_existing_cache_entry_for_the_same_plugin() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_plugins(&[Path::new(BLANK_ESM)]).unwrap(); + let plugin1: *const str = game.plugin(BLANK_ESM).unwrap().name(); + let plugin2: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_eq!(plugin1, plugin2); + + game.load_plugins(&[Path::new(BLANK_ESM)]).unwrap(); + + let plugin3: *const str = game.plugin(BLANK_ESM).unwrap().name(); + + assert_ne!(plugin2, plugin3); + } + + #[apply(all_game_types)] + fn should_error_if_loading_a_plugin_with_a_master_that_is_not_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths = &[Path::new(BLANK_MASTER_DEPENDENT_ESM)]; + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + match game.load_plugins(paths) { + Err(LoadPluginsError::PluginDataError(e)) => { + let source = e.source().unwrap(); + match source.downcast_ref::().unwrap() { + esplugin::Error::PluginMetadataNotFound(p) => { + if game_type == GameType::Starfield { + assert_eq!(BLANK_FULL_ESM, p) + } else { + assert_eq!(BLANK_ESM, p) + }; + } + _ => panic!("Unexpected esplugin error: {e}"), + } + } + _ => panic!("Expected an error due to esplugin metadata not found"), + } + } else { + game.load_plugins(paths).unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } + } + + #[apply(all_game_types)] + fn should_not_error_if_loading_a_plugin_with_a_master_that_is_also_being_loaded_if_game_is_morrowind_or_starfield( + game_type: GameType, + ) { + let fixture = Fixture::new(game_type); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths: &[&Path] = if game_type == GameType::Starfield { + &[ + Path::new(BLANK_MASTER_DEPENDENT_ESM), + Path::new(BLANK_FULL_ESM), + ] + } else { + &[Path::new(BLANK_MASTER_DEPENDENT_ESM), Path::new(BLANK_ESM)] + }; + + game.load_plugins(paths).unwrap(); + + assert!(game.plugin(BLANK_MASTER_DEPENDENT_ESM).is_some()); + } + } + + mod load_plugins_common { + use crate::tests::{BLANK_ESM, BLANK_MASTER_DEPENDENT_ESM}; + + use super::*; + + #[apply(all_game_types)] + fn should_find_archives_in_additional_data_paths(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let extension = if matches!( + game_type, + GameType::FO4 | GameType::FO4VR | GameType::Starfield + ) { + ".ba2" + } else { + ".bsa" + }; + + let path1 = fixture + .game_path + .join("sub1") + .join("archive") + .with_extension(extension); + let path2 = fixture + .game_path + .join("sub2") + .join("archive") + .with_extension(extension); + std::fs::create_dir(path1.parent().unwrap()).unwrap(); + std::fs::create_dir(path2.parent().unwrap()).unwrap(); + std::fs::File::create(&path1).unwrap(); + std::fs::File::create(&path2).unwrap(); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.set_additional_data_paths(&[path1.parent().unwrap(), path2.parent().unwrap()]) + .unwrap(); + + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(HashSet::from([path1, path2]), game.cache.archive_paths); + } + + #[test] + fn should_clear_the_archive_cache_before_finding_archives() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + std::fs::File::create(fixture.data_path().join("Blank.bsa")).unwrap(); + + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + game.load_plugins_common(&[], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, game.cache.archive_paths.len()); + } + + #[test] + fn should_not_error_if_an_installed_filename_has_non_windows_1252_encodable_characters() + { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let filename = + "\u{2551}\u{00BB}\u{00C1}\u{2510}\u{2557}\u{00FE}\u{00C3}\u{00CE}.txt"; + std::fs::File::create(fixture.data_path().join(filename)).unwrap(); + + assert!(game.load_plugins_common(&[], LoadScope::HeaderOnly).is_ok()); + } + + #[test] + fn should_error_given_duplicate_filenames() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let paths = &[ + Path::new("a").join(BLANK_ESM), + Path::new("b").join(BLANK_ESM), + ]; + + match game.load_plugins_common(&[&paths[0], &paths[1]], LoadScope::HeaderOnly) { + Err(LoadPluginsError::PluginValidationError(e)) => { + assert_eq!( + format!( + "the path \"{}\" has a filename that is not unique", + paths[1].display() + ), + e.to_string() + ); + } + _ => panic!("Expected an error due to duplicate filenames"), + } + } + + #[test] + fn should_resolve_relative_paths_relative_to_the_data_path() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = Path::new("..") + .join(fixture.data_path().file_name().unwrap()) + .join(BLANK_ESM); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_ESM, plugins[0].name()); + } + + #[test] + fn should_use_absolute_paths_as_given() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture.data_path().join(BLANK_ESM); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_ESM, plugins[0].name()); + } + + #[test] + fn should_trim_ghost_extensions_from_loaded_plugin_names() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + let path = fixture + .data_path() + .join(format!("{}.ghost", BLANK_MASTER_DEPENDENT_ESM)); + + let plugins = game + .load_plugins_common(&[&path], LoadScope::HeaderOnly) + .unwrap(); + + assert_eq!(1, plugins.len()); + assert_eq!(BLANK_MASTER_DEPENDENT_ESM, plugins[0].name()); + } + } + + #[test] + fn clear_loaded_plugins_should_clear_the_plugins_cache() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_plugin_headers(&[Path::new(BLANK_ESM)]).unwrap(); + + assert!(!game.cache.plugins.is_empty()); + + game.clear_loaded_plugins(); + + assert!(game.cache.plugins.is_empty()); + } + + mod sort_plugins { + use crate::tests::{BLANK_DIFFERENT_ESP, BLANK_ESP, initial_load_order}; + + use super::*; + + fn load_all_installed_plugins(game: &mut Game, fixture: &Fixture) { + let load_order = initial_load_order(fixture.game_type); + + let plugins: Vec<_> = load_order.iter().map(|(n, _)| Path::new(n)).collect(); + + game.load_current_load_order_state().unwrap(); + game.load_plugins(&plugins).unwrap(); + } + + #[test] + fn should_return_an_empty_list_if_given_an_empty_list() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.sort_plugins(&[]).unwrap().is_empty()); + } + + #[test] + fn should_only_sort_the_given_plugins() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + load_all_installed_plugins(&mut game, &fixture); + + let input = &[BLANK_ESP, BLANK_DIFFERENT_ESP]; + let sorted = game.sort_plugins(input).unwrap(); + + assert_eq!(input, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_given_plugin_is_not_loaded() { + let fixture = Fixture::new(GameType::TES4); + + let game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + assert!(game.sort_plugins(&[BLANK_ESP]).is_err()); + } + } + + mod is_plugin_active { + use crate::tests::BLANK_ESP; + + use super::*; + + #[test] + fn should_be_independent_of_plugins_being_loaded() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = Game::with_local_path( + fixture.game_type, + &fixture.game_path, + &fixture.local_path, + ) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + + let paths = &[Path::new(BLANK_ESM), Path::new(BLANK_ESP)]; + game.load_plugin_headers(paths).unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + + game.load_plugins(paths).unwrap(); + + assert!(game.is_plugin_active(BLANK_ESM)); + assert!(!game.is_plugin_active(BLANK_ESP)); + } + } + + #[test] + fn set_load_order_should_persist_the_given_load_order() { + let fixture = Fixture::new(GameType::TES4); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + let mut load_order: Vec<_> = game.load_order().iter().map(|s| s.to_string()).collect(); + load_order.swap(7, 10); + let load_order: Vec<_> = load_order.iter().map(|s| s.as_str()).collect(); + + game.set_load_order(&load_order).unwrap(); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + game.load_current_load_order_state().unwrap(); + + assert_eq!(load_order, game.load_order()); + } + + #[test] + fn should_support_loading_plugins_and_metadata_in_parallel() { + let fixture = Fixture::new(GameType::TES3); + + let mut game = + Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path) + .unwrap(); + + let masterlist_path = fixture.local_path.join("masterlist.yaml"); + std::fs::write(&masterlist_path, "bash_tags: [Relev]").unwrap(); + + std::thread::scope(|s| { + let database = game.database(); + s.spawn(move || { + if let Ok(mut database) = database.write() { + database.load_masterlist(&masterlist_path).unwrap(); + } + }); + s.spawn(|| { + game.load_plugins(&[]).unwrap(); + }); + }); } } - mod with_local_path { + mod game_cache { use super::*; - #[apply(all_game_types)] - fn should_succeed_if_given_valid_paths(game_type: GameType) { - let fixture = Fixture::new(game_type); + use crate::tests::{BLANK_ESM, source_plugins_path}; - let game = - Game::with_local_path(fixture.game_type, &fixture.game_path, &fixture.local_path); + mod insert_plugins { - assert!(game.is_ok()); + use super::*; + + #[test] + fn should_add_plugins_not_already_cached() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert_eq!(BLANK_ESM, cache.plugin(BLANK_ESM).unwrap().name()); + } + + #[test] + fn should_replace_plugins_that_are_already_cached() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert!(cache.plugin(BLANK_ESM).unwrap().crc().is_none()); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::WholePlugin, + ) + .unwrap(), + ]); + + assert!(cache.plugin(BLANK_ESM).unwrap().crc().is_some()); + } + } + + mod plugin { + use super::*; + + #[test] + fn should_be_case_insensitive() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert_eq!("Blank.esm", cache.plugin("blank.esm").unwrap().name()); + } + + #[test] + fn should_return_none_if_the_plugin_is_not_cached() { + let cache = GameCache::default(); + + assert!(cache.plugin(BLANK_ESM).is_none()); + } + } + + mod clear_plugins { + use super::*; + + #[test] + fn should_clear_any_cached_plugins() { + let mut cache = GameCache::default(); + + cache.insert_plugins(vec![ + Plugin::new( + GameType::TES4, + &cache, + &source_plugins_path(GameType::TES4).join(BLANK_ESM), + LoadScope::HeaderOnly, + ) + .unwrap(), + ]); + + assert!(!cache.plugins.is_empty()); + + cache.clear_plugins(); + + assert!(cache.plugins.is_empty()); + } } } } diff --git a/src/metadata/file.rs b/src/metadata/file.rs index dea1bd84..4556a0b0 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -194,6 +194,131 @@ impl EmitYaml for File { mod tests { use super::*; + mod file_eq { + use super::*; + + #[test] + fn should_be_case_insensitive_on_name() { + assert_eq!(File::new("name".into()), File::new("name".into())); + assert_eq!(File::new("name".into()), File::new("NAME".into())); + assert_ne!(File::new("name1".into()), File::new("name2".into())); + } + } + + mod filename_eq { + use super::*; + + #[test] + fn should_be_case_insensitive_on_name() { + assert_eq!(Filename::new("name".into()), Filename::new("name".into())); + assert_eq!(Filename::new("name".into()), Filename::new("NAME".into())); + assert_ne!(Filename::new("name1".into()), Filename::new("name2".into())); + } + } + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_if_decoding_from_scalar() { + let yaml = parse("name1"); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert!(file.display_name().is_none()); + assert!(file.condition().is_none()); + assert!(file.detail().is_empty()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(File::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{display: display1}"); + + assert!(File::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{name: name1, condition: invalid}"); + + assert!(File::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse( + "{name: name1, display: display1, condition: 'file(\"Foo.esp\")', detail: 'details'}", + ); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert_eq!("display1", file.display_name().unwrap()); + assert_eq!("file(\"Foo.esp\")", file.condition().unwrap()); + assert_eq!(&[MessageContent::new("details".into())], file.detail()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: name1}"); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!("name1", file.name().as_str()); + assert!(file.display_name().is_none()); + assert!(file.condition().is_none()); + assert!(file.detail().is_empty()); + } + + #[test] + fn should_read_all_listed_detail_message_contents() { + let yaml = parse( + "{name: name1, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + file.detail() + ); + } + + #[test] + fn should_not_error_if_one_detail_is_given_and_it_is_not_english() { + let yaml = parse("name: name1\ndetail:\n - lang: fr\n text: content1"); + + let file = File::try_from(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + file.detail() + ); + } + + #[test] + fn should_error_if_multiple_details_are_given_and_none_are_english() { + let yaml = parse( + "name: name1\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(File::try_from(&yaml).is_err()); + } + } + mod emit_yaml { use crate::metadata::emit; diff --git a/src/metadata/group.rs b/src/metadata/group.rs index e5124676..104b5b76 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -126,6 +126,48 @@ impl EmitYaml for Group { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Group::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{description: text}"); + + assert!(Group::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{name: group1, description: text, after: [ other_group ]}"); + + let group = Group::try_from(&yaml).unwrap(); + + assert_eq!("group1", group.name()); + assert_eq!("text", group.description().unwrap()); + assert_eq!(&["other_group"], group.after_groups()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: group1}"); + + let group = Group::try_from(&yaml).unwrap(); + + assert_eq!("group1", group.name()); + assert!(group.description().is_none()); + assert!(group.after_groups().is_empty()); + } + } + mod emit_yaml { use super::*; use crate::metadata::emit; diff --git a/src/metadata/location.rs b/src/metadata/location.rs index bb79fc0b..66872672 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -105,6 +105,53 @@ impl EmitYaml for Location { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_if_decoding_from_scalar() { + let yaml = parse("https://www.example.com"); + + let location = Location::try_from(&yaml).unwrap(); + + assert_eq!("https://www.example.com", location.url()); + assert!(location.name().is_none()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Location::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_link_is_missing() { + let yaml = parse("{name: example}"); + + assert!(Location::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{link: https://www.example.com}"); + + assert!(Location::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_fields() { + let yaml = parse("{link: https://www.example.com, name: example}"); + + let location = Location::try_from(&yaml).unwrap(); + + assert_eq!("https://www.example.com", location.url()); + assert_eq!("example", location.name().unwrap()); + } + } + mod emit_yaml { use crate::metadata::emit; @@ -112,7 +159,7 @@ mod tests { #[test] fn should_emit_url_only_if_there_is_no_name() { - let location = Location::new("http://www.example.com".into()); + let location = Location::new("https://www.example.com".into()); let yaml = emit(&location); assert_eq!(format!("'{}'", location.url), yaml); @@ -121,7 +168,7 @@ mod tests { #[test] fn should_emit_map_if_there_is_a_name() { let location = - Location::new("http://www.example.com".into()).with_name("example".into()); + Location::new("https://www.example.com".into()).with_name("example".into()); let yaml = emit(&location); assert_eq!( diff --git a/src/metadata/message.rs b/src/metadata/message.rs index dedda515..1c31868c 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -453,14 +453,128 @@ mod tests { #[test] fn should_return_the_only_element_of_a_single_element_slice() { let slice = &[MessageContent::new("test".into()).with_language("de".into())]; - let content = select_message_content(slice, "fr"); + let content = select_message_content(slice, "fr").unwrap(); - assert_eq!(&slice[0], content.unwrap()); + assert_eq!(&slice[0], content); + } + + #[test] + fn should_return_element_with_exactly_matching_locale_code() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt".into()), + MessageContent::new("test4".into()).with_language("pt_PT".into()), + MessageContent::new("test5".into()).with_language("pt_BR".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[4], content); + } + + #[test] + fn should_return_element_with_matching_language_code_if_exactly_matching_local_code_is_not_present() + { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt".into()), + MessageContent::new("test4".into()).with_language("pt_PT".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[2], content); + } + + #[test] + fn should_return_element_with_en_language_code_if_no_matching_language_code_is_present() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + ]; + + let content = select_message_content(slice, "pt_BR").unwrap(); + + assert_eq!(&slice[0], content); + } + + #[test] + fn should_return_element_with_exactly_matching_language_code_if_language_code_is_given() { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_BR".into()), + MessageContent::new("test4".into()).with_language("pt".into()), + ]; + + let content = select_message_content(slice, "pt").unwrap(); + + assert_eq!(&slice[3], content); + } + + #[test] + fn should_return_first_element_with_matching_language_code_if_language_code_is_given_and_no_exact_match_is_present() + { + let slice = &[ + MessageContent::new("test1".into()).with_language("en".into()), + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + MessageContent::new("test4".into()).with_language("pt_BR".into()), + ]; + + let content = select_message_content(slice, "pt").unwrap(); + + assert_eq!(&slice[2], content); + } + + #[test] + fn should_return_none_if_there_is_no_match_and_no_english_text() { + let slice = &[ + MessageContent::new("test2".into()).with_language("de".into()), + MessageContent::new("test3".into()).with_language("pt_PT".into()), + MessageContent::new("test4".into()).with_language("pt_BR".into()), + ]; + + assert!(select_message_content(slice, "fr").is_none()); } } mod message_content { use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("content"); + + assert!(MessageContent::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(MessageContent::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{text: content, lang: fr}"); + + let content = MessageContent::try_from(&yaml).unwrap(); + + assert_eq!("content", content.text()); + assert_eq!("fr", content.language()); + } + } + mod emit_yaml { use super::*; @@ -479,6 +593,182 @@ mod tests { mod message { use super::*; + + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("content"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_content_is_missing() { + let yaml = parse("{type: say}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{type: say, content: text, condition: invalid}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = parse("{type: say, content: text, condition: 'file(\"Foo.esp\")'}"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!(MessageType::Say, message.message_type()); + assert_eq!(&[MessageContent::new("text".into())], message.content()); + assert_eq!("file(\"Foo.esp\")", message.condition().unwrap()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{type: say, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!(MessageType::Say, message.message_type()); + assert_eq!(&[MessageContent::new("text".into())], message.content()); + assert!(message.condition().is_none()); + } + + #[test] + fn should_set_say_warn_and_error_message_types() { + let yaml = parse("{type: say, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Say, message.message_type()); + + let yaml = parse("{type: warn, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Warn, message.message_type()); + + let yaml = parse("{type: error, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Error, message.message_type()); + } + + #[test] + fn should_use_say_if_message_type_is_unrecognised() { + let yaml = parse("{type: info, content: text}"); + + let message = Message::try_from(&yaml).unwrap(); + assert_eq!(MessageType::Say, message.message_type()); + } + + #[test] + fn should_read_all_listed_message_contents() { + let yaml = parse( + "{type: say, content: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + message.content() + ); + } + + #[test] + fn should_not_error_if_one_content_object_is_given_and_it_is_not_english() { + let yaml = parse("type: say\ncontent:\n - lang: fr\n text: content1"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + message.content() + ); + } + + #[test] + fn should_error_if_multiple_contents_are_given_and_none_are_english() { + let yaml = parse( + "type: say\ncontent:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_apply_substitutions_when_there_is_only_one_content_string() { + let yaml = parse("type: say\ncontent: con{0}tent1\nsubs:\n - sub1"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("consub1tent1", message.content()[0].text()); + } + + #[test] + fn should_apply_substitutions_to_all_content_strings() { + let yaml = parse( + "type: say\ncontent:\n - lang: en\n text: content1 {0}\n - lang: fr\n text: content2 {0}\nsubs:\n - sub", + ); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("content1 sub", message.content()[0].text()); + assert_eq!("content2 sub", message.content()[1].text()); + } + + #[test] + fn should_error_if_the_message_has_more_substitutions_than_expected() { + let yaml = parse("{type: say, content: 'content1', subs: [sub1]}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_the_content_string_expects_more_substitutions_than_exist() { + let yaml = parse("{type: say, content: '{0} {1}', subs: [sub1]}"); + + assert!(Message::try_from(&yaml).is_err()); + } + + #[test] + fn should_ignore_substution_syntax_if_no_substitutions_exist() { + let yaml = parse("{type: say, content: 'content {0}'}"); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("content {0}", message.content()[0].text()); + } + + #[test] + fn should_accept_percentage_placeholder_syntax() { + let yaml = parse( + "{type: say, content: 'content %1% %2% %3% %4% %5% %6% %7% %8% %9% %10% %11%', subs: [a, b, c, d, e, f, g, h, i, j, k]}", + ); + + let message = Message::try_from(&yaml).unwrap(); + + assert_eq!("content a b c d e f g h i j k", message.content()[0].text()); + } + } + mod emit_yaml { use super::*; diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 3317b98b..dde340f0 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -237,7 +237,7 @@ impl MetadataDocument { emitter.begin_array(); - for plugin in self.plugins() { + for plugin in self.plugins_iter() { if !plugin.has_name_only() { plugin.emit_yaml(&mut emitter); } @@ -269,7 +269,7 @@ impl MetadataDocument { &self.messages } - pub fn plugins(&self) -> impl Iterator { + pub fn plugins_iter(&self) -> impl Iterator { self.plugins.values().chain(self.regex_plugins.iter()) } @@ -477,9 +477,14 @@ fn indent_prelude(prelude: String, line_ending: &str) -> String { mod tests { use tempfile::tempdir; + use crate::metadata::File; + use super::*; - const METADATA_LIST_YAML: &str = r#"bash_tags: + mod metadata_document { + use super::*; + + const METADATA_LIST_YAML: &str = r#"bash_tags: - 'C.Climate' - 'Relev' @@ -520,10 +525,9 @@ plugins: util: utility "#; - #[test] - fn load_should_resolve_aliases() { - let tmp_dir = tempdir().unwrap(); - let yaml = r#" + #[test] + fn load_from_str_should_resolve_aliases() { + let yaml = r#" prelude: - &anchor type: say @@ -533,17 +537,13 @@ plugins: - *anchor "#; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, yaml).unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list.load_from_str(yaml).unwrap(); + } - let mut metadata_list = MetadataDocument::default(); - metadata_list.load(&path).unwrap(); - } - - #[test] - fn load_should_resolve_merge_keys() { - let tmp_dir = tempdir().unwrap(); - let yaml = r#" + #[test] + fn load_from_str_should_resolve_merge_keys() { + let yaml = r#" prelude: - &anchor type: say @@ -554,80 +554,527 @@ plugins: condition: file("test.esp") "#; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, yaml).unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list.load_from_str(yaml).unwrap(); + } - let mut metadata_list = MetadataDocument::default(); - metadata_list.load(&path).unwrap(); - } + #[test] + fn load_from_str_should_error_if_a_plugin_has_two_exact_entries() { + let yaml = r#" +plugins: + - name: 'Blank.esm' + msg: + - type: warn + content: 'This is a warning.' - #[test] - fn load_should_deserialise_masterlist() { - let tmp_dir = tempdir().unwrap(); + - name: 'Blank.esm' + msg: + - type: error + content: 'This plugin entry will cause a failure, as it is not the first exact entry.' + "#; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load_from_str(yaml).is_err()); + } - let mut metadata_list = MetadataDocument::default(); - metadata_list.load(&path).unwrap(); - } + #[test] + fn load_should_deserialise_masterlist() { + let tmp_dir = tempdir().unwrap(); - #[test] - fn load_with_prelude_should_merge_docs_with_crlf_line_endings() { - let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); - let masterlist_path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\nglobals:\r\n - *ref\r\n").unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); - let prelude_path = tmp_dir.path().join("prelude.yaml"); - std::fs::write( - &prelude_path, - "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n", - ) - .unwrap(); + let plugin_names: Vec<_> = metadata_list + .plugins_iter() + .map(PluginMetadata::name) + .collect(); + assert!(plugin_names.contains(&"Blank.esm")); + assert!(plugin_names.contains(&"Blank.esp")); + assert!(plugin_names.contains(&"Blank.+\\.esp")); + assert!(plugin_names.contains(&"Blank.+(Different)?.*\\.esp")); - let mut metadata_list = MetadataDocument::default(); - metadata_list - .load_with_prelude(&masterlist_path, &prelude_path) + assert_eq!(&["C.Climate", "Relev"], metadata_list.bash_tags()); + + let groups = metadata_list.groups(); + assert_eq!(3, groups.len()); + + assert_eq!("default", groups[0].name()); + assert!(groups[0].after_groups().is_empty()); + + assert_eq!("group1", groups[1].name()); + assert_eq!(&["group2"], groups[1].after_groups()); + + assert_eq!("group2", groups[2].name()); + assert_eq!(&["default"], groups[2].after_groups()); + } + + #[test] + fn load_should_error_if_an_invalid_metadata_file_is_given() { + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("masterlist.yaml"); + let yaml = r#" + - 'C.Climate' + - 'Relev' + +globals: + - type: say + content: 'A global message.' + +plugins: + - name: 'Blank.+\.esp' + after: + - 'Blank.esm' + "#; + + std::fs::write(&path, yaml).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load(&path).is_err()); + } + + #[test] + fn load_should_error_if_the_given_path_does_not_exist() { + let mut metadata_list = MetadataDocument::default(); + assert!(metadata_list.load(Path::new("missing")).is_err()); + } + + #[test] + fn load_with_prelude_should_merge_docs_with_crlf_line_endings() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\r\n - &ref\r\n type: say\r\n content: Loaded from same file\r\nglobals:\r\n - *ref\r\n").unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\r\n - &ref\r\n type: say\r\n content: Loaded from prelude\r\n", + ) .unwrap(); - } - #[test] - fn load_with_prelude_should_merge_docs_with_lf_line_endings() { - let tmp_dir = tempdir().unwrap(); + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + } - let masterlist_path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); + #[test] + fn load_with_prelude_should_merge_docs_with_lf_line_endings() { + let tmp_dir = tempdir().unwrap(); - let prelude_path = tmp_dir.path().join("prelude.yaml"); - std::fs::write( - &prelude_path, - "common:\n - &ref\n type: say\n content: Loaded from prelude\n", - ) - .unwrap(); + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); - let mut metadata_list = MetadataDocument::default(); - metadata_list - .load_with_prelude(&masterlist_path, &prelude_path) + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\n - &ref\n type: say\n content: Loaded from prelude\n", + ) .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list + .load_with_prelude(&masterlist_path, &prelude_path) + .unwrap(); + } + + #[test] + fn load_with_prelude_should_error_if_the_given_masterlist_path_does_not_exist() { + let tmp_dir = tempdir().unwrap(); + + let prelude_path = tmp_dir.path().join("prelude.yaml"); + std::fs::write( + &prelude_path, + "common:\n - &ref\n type: say\n content: Loaded from prelude\n", + ) + .unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!( + metadata_list + .load_with_prelude(Path::new("missing"), &prelude_path) + .is_err() + ); + } + + #[test] + fn load_with_prelude_should_error_if_the_given_prelude_path_does_not_exist() { + let tmp_dir = tempdir().unwrap(); + + let masterlist_path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&masterlist_path, "prelude:\n - &ref\n type: say\n content: Loaded from same file\nglobals:\n - *ref\n").unwrap(); + + let mut metadata_list = MetadataDocument::default(); + assert!( + metadata_list + .load_with_prelude(&masterlist_path, Path::new("missing")) + .is_err() + ); + } + + #[test] + fn save_should_write_the_loaded_metadata() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + + let mut metadata = MetadataDocument::default(); + metadata.load(&path).unwrap(); + + let other_path = tmp_dir.path().join("other.yaml"); + metadata.save(&other_path).unwrap(); + + let mut other_metadata = MetadataDocument::default(); + other_metadata.load(&other_path).unwrap(); + + assert_eq!(metadata, other_metadata); + } + + #[test] + fn clear_should_clear_all_loaded_data() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + assert!(!metadata.messages().is_empty()); + assert!(metadata.plugins_iter().next().is_some()); + assert!(!metadata.bash_tags().is_empty()); + + metadata.clear(); + + assert!(metadata.messages().is_empty()); + assert!(metadata.plugins_iter().next().is_none()); + assert!(metadata.bash_tags().is_empty()); + } + + #[test] + fn set_groups_should_replace_existing_groups() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + metadata.set_groups(vec![Group::new("group4".into())]); + + let groups = metadata.groups(); + + assert_eq!("default", groups[0].name()); + assert!(groups[0].after_groups().is_empty()); + + assert_eq!("group4", groups[1].name()); + assert!(groups[1].after_groups().is_empty()); + } + + #[test] + fn find_plugin_should_return_none_if_the_given_plugin_has_no_metadata() { + let metadata = MetadataDocument::default(); + assert!(metadata.find_plugin("Blank.esp").unwrap().is_none()); + } + + #[test] + fn find_plugin_should_return_the_metadata_object_if_one_exists() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank - Different.esp"; + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!(&[File::new("Blank.esm".into())], plugin.load_after_files()); + assert_eq!(&[File::new("Blank.esp".into())], plugin.incompatibilities()); + } + + #[test] + fn add_plugin_should_store_specific_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + + let name = "Blank.esp"; + let mut plugin = PluginMetadata::new(name).unwrap(); + plugin.set_group("group1".into()); + metadata.set_plugin_metadata(plugin); + + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!("group1", plugin.group().unwrap()); + } + + #[test] + fn add_plugin_should_store_given_regex_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + + let mut plugin = PluginMetadata::new(".+Dependent\\.esp").unwrap(); + plugin.set_group("group1".into()); + metadata.set_plugin_metadata(plugin); + + let name = "Blank - Plugin Dependent.esp"; + let plugin = metadata.find_plugin(name).unwrap().unwrap(); + + assert_eq!(name, plugin.name()); + assert_eq!("group1", plugin.group().unwrap()); + } + + #[test] + fn remove_plugin_metadata_should_remove_the_given_plugin_specific_metadata() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank.esp"; + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata(name); + + assert!(metadata.find_plugin(name).unwrap().is_none()); + } + + #[test] + fn remove_plugin_metadata_should_not_remove_matching_regex_plugin_metadata() { + let mut metadata = MetadataDocument::default(); + metadata.load_from_str(METADATA_LIST_YAML).unwrap(); + + let name = "Blank.+\\.esp"; + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata(name); + + assert!(metadata.find_plugin(name).unwrap().is_some()); + + metadata.remove_plugin_metadata("Blank - Different.esp"); + + assert!(metadata.find_plugin(name).unwrap().is_some()); + } } - #[test] - fn save_should_write_the_loaded_metadata() { - let tmp_dir = tempdir().unwrap(); + mod replace_prelude { + use super::*; - let path = tmp_dir.path().join("masterlist.yaml"); - std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + #[test] + fn should_return_an_empty_string_if_given_empty_strings() { + let result = replace_prelude(String::new(), String::new()); - let mut metadata = MetadataDocument::default(); - metadata.load(&path).unwrap(); + assert!(result.is_empty()); + } - let other_path = tmp_dir.path().join("other.yaml"); - metadata.save(&other_path).unwrap(); + #[test] + fn should_not_change_a_masterlist_with_no_prelude() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "plugins: + - name: a.esp +"; - let mut other_metadata = MetadataDocument::default(); - other_metadata.load(&other_path).unwrap(); + let result = replace_prelude(masterlist.into(), prelude.into()); - assert_eq!(metadata, other_metadata); + assert_eq!(masterlist, result); + } + + #[test] + fn should_not_change_a_flow_style_masterlist() { + let prelude = "globals: [{type: note, content: A message.}]"; + let masterlist = "{prelude: {}, plugins: [{name: a.esp}]}"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + assert_eq!(masterlist, result); + } + + #[test] + fn should_replace_a_prelude_at_the_start_of_the_masterlist() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_change_a_masterlist_that_ends_with_a_prelude() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "plugins: + - name: a.esp +prelude: + a: b + +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "plugins: + - name: a.esp +prelude: + globals: + - type: note + content: A message. +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_replace_only_the_prelude_in_the_masterlist() { + let prelude = " + +globals: + - type: note + content: A message. + +"; + let masterlist = " +common: + key: value +prelude: + a: b +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = " +common: + key: value +prelude: + + + globals: + - type: note + content: A message. + + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_succeed_given_block_style_prelude_and_masterlist() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_succeed_given_a_flow_style_prelude_and_a_block_style_masterlist() { + let prelude = "globals: [{type: note, content: A message.}]"; + let masterlist = "prelude: + a: b + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: [{type: note, content: A message.}] +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_not_stop_at_comments() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b +# Comment line + c: d + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } + + #[test] + fn should_not_stop_at_a_blank_line() { + let prelude = "globals: + - type: note + content: A message. +"; + let masterlist = "prelude: + a: b + + +plugins: + - name: a.esp +"; + + let result = replace_prelude(masterlist.into(), prelude.into()); + + let expected_result = "prelude: + globals: + - type: note + content: A message. + +plugins: + - name: a.esp +"; + + assert_eq!(expected_result, result); + } } } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index a0245cd0..d430da2c 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -26,3 +26,11 @@ fn emit(metadata: &T) -> String { emitter.into_string() } + +#[cfg(test)] +fn parse(yaml: &str) -> saphyr::MarkedYaml { + saphyr::MarkedYaml::load_from_str(yaml) + .unwrap() + .pop() + .unwrap() +} diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index caae3508..db990fc2 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -190,6 +190,108 @@ impl EmitYaml for PluginCleaningData { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("0x12345678"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_crc_is_missing() { + let yaml = parse("{util: cleaner}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_util_is_missing() { + let yaml = parse("{crc: 0x12345678}"); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_given_fields() { + let yaml = + parse("{crc: 0x12345678, util: cleaner, detail: info, itm: 2, udr: 10, nav: 30}"); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!(0x12345678, data.crc()); + assert_eq!("cleaner", data.cleaning_utility()); + assert_eq!(&[MessageContent::new("info".into())], data.detail()); + assert_eq!(2, data.itm_count()); + assert_eq!(10, data.deleted_reference_count()); + assert_eq!(30, data.deleted_navmesh_count()); + } + + #[test] + fn should_leave_optional_fields_at_defaults_if_not_present() { + let yaml = parse("{crc: 0x12345678, util: cleaner}"); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!(0x12345678, data.crc()); + assert_eq!("cleaner", data.cleaning_utility()); + assert!(data.detail().is_empty()); + assert_eq!(0, data.itm_count()); + assert_eq!(0, data.deleted_reference_count()); + assert_eq!(0, data.deleted_navmesh_count()); + } + + #[test] + fn should_read_all_listed_detail_message_contents() { + let yaml = parse( + "{crc: 0x12345678, util: cleaner, detail: [{text: english, lang: en}, {text: french, lang: fr}]}", + ); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!( + &[ + MessageContent::new("english".into()), + MessageContent::new("french".into()).with_language("fr".into()) + ], + data.detail() + ); + } + + #[test] + fn should_not_error_if_one_detail_is_given_and_it_is_not_english() { + let yaml = + parse("crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: fr\n text: content1"); + + let data = PluginCleaningData::try_from(&yaml).unwrap(); + + assert_eq!( + &[MessageContent::new("content1".into()).with_language("fr".into())], + data.detail() + ); + } + + #[test] + fn should_error_if_multiple_details_are_given_and_none_are_english() { + let yaml = parse( + "crc: 0x12345678\nutil: cleaner\ndetail:\n - lang: de\n text: content1\n - lang: fr\n text: content2", + ); + + assert!(PluginCleaningData::try_from(&yaml).is_err()); + } + } + mod emit_yaml { use crate::metadata::emit; diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index f599eb23..84ff20d0 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -419,8 +419,380 @@ impl EmitYaml for PluginMetadata { #[cfg(test)] mod tests { + use crate::{ + metadata::{MessageType, TagSuggestion}, + tests::{BLANK_DIFFERENT_ESM, BLANK_DIFFERENT_ESP, BLANK_ESM, BLANK_ESP}, + }; + use super::*; + mod name_matches { + use super::*; + + #[test] + fn should_use_case_insensitive_comparison_for_non_regex_names() { + let plugin = PluginMetadata::new("BLANK.ESM").unwrap(); + + assert!(plugin.name_matches("blank.esm")); + assert!(!plugin.name_matches("other.esm")); + } + + #[test] + fn should_treat_given_plugin_name_as_literal() { + let plugin = PluginMetadata::new("Blank.esm").unwrap(); + + assert!(!plugin.name_matches(".+")); + } + + #[test] + fn should_use_case_insensitive_regex_matching_for_a_regex_name() { + let plugin = PluginMetadata::new("Blank.ES(m|p)").unwrap(); + + assert!(plugin.name_matches("blank.esm")); + } + } + + mod merge_metadata { + use super::*; + + #[test] + fn should_not_change_name() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let plugin2 = PluginMetadata::new(BLANK_DIFFERENT_ESM).unwrap(); + + plugin1.merge_metadata(&plugin2); + + assert_eq!(BLANK_ESM, plugin1.name()); + } + + #[test] + fn should_not_use_other_group_if_current_group_is_set() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin1.set_group("group1".into()); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin2.set_group("group2".into()); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group1", plugin1.group().unwrap()); + + plugin2.unset_group(); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group1", plugin1.group().unwrap()); + } + + #[test] + fn should_use_other_group_if_current_group_is_none() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin2.set_group("group2".into()); + + plugin1.merge_metadata(&plugin2); + + assert_eq!("group2", plugin1.group().unwrap()); + } + + #[test] + fn should_merge_load_after_files() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_load_after_files(vec![file1.clone(), file2.clone()]); + plugin2.set_load_after_files(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.load_after_files() + ); + } + + #[test] + fn should_merge_requirements() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_requirements(vec![file1.clone(), file2.clone()]); + plugin2.set_requirements(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.requirements() + ); + } + + #[test] + fn should_merge_incompatibilities() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let file1 = File::new(BLANK_DIFFERENT_ESM.into()); + let file2 = File::new(BLANK_ESP.into()); + let file3 = File::new(BLANK_DIFFERENT_ESP.into()); + plugin1.set_incompatibilities(vec![file1.clone(), file2.clone()]); + plugin2.set_incompatibilities(vec![file1.clone(), file3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[file1.clone(), file2.clone(), file3.clone()], + plugin1.incompatibilities() + ); + } + + #[test] + fn should_merge_messages() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let message1 = Message::new(MessageType::Say, "content1".into()); + let message2 = Message::new(MessageType::Say, "content2".into()); + let message3 = Message::new(MessageType::Say, "content3".into()); + plugin1.set_messages(vec![message1.clone(), message2.clone()]); + plugin2.set_messages(vec![message1.clone(), message3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[ + message1.clone(), + message2.clone(), + message1.clone(), + message3.clone() + ], + plugin1.messages() + ); + } + + #[test] + fn should_merge_tags() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let tag1 = Tag::new("Relev".into(), TagSuggestion::Addition); + let tag2 = Tag::new("Delev".into(), TagSuggestion::Addition); + let tag3 = Tag::new("Relev".into(), TagSuggestion::Removal); + plugin1.set_tags(vec![tag1.clone(), tag2.clone()]); + plugin2.set_tags(vec![tag1.clone(), tag3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!(&[tag1.clone(), tag2.clone(), tag3.clone()], plugin1.tags()); + } + + #[test] + fn should_merge_dirty_info() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let data1 = PluginCleaningData::new(0x12345678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEADBEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEEDCAFE, "util3".into()); + plugin1.set_dirty_info(vec![data1.clone(), data2.clone()]); + plugin2.set_dirty_info(vec![data1.clone(), data3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[data1.clone(), data2.clone(), data3.clone()], + plugin1.dirty_info() + ); + } + + #[test] + fn should_merge_clean_info() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let data1 = PluginCleaningData::new(0x12345678, "util1".into()); + let data2 = PluginCleaningData::new(0xDEADBEEF, "util2".into()); + let data3 = PluginCleaningData::new(0xFEEDCAFE, "util3".into()); + plugin1.set_clean_info(vec![data1.clone(), data2.clone()]); + plugin2.set_clean_info(vec![data1.clone(), data3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[data1.clone(), data2.clone(), data3.clone()], + plugin1.clean_info() + ); + } + + #[test] + fn should_merge_locations() { + let mut plugin1 = PluginMetadata::new(BLANK_ESM).unwrap(); + let mut plugin2 = PluginMetadata::new(BLANK_ESM).unwrap(); + + let location1 = Location::new("url1".into()); + let location2 = Location::new("url2".into()); + let location3 = Location::new("url3".into()); + plugin1.set_locations(vec![location1.clone(), location2.clone()]); + plugin2.set_locations(vec![location1.clone(), location3.clone()]); + + plugin1.merge_metadata(&plugin2); + + assert_eq!( + &[location1.clone(), location2.clone(), location3.clone()], + plugin1.locations() + ); + } + } + + #[test] + fn unset_group_should_set_group_to_none() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + plugin.set_group("group1".into()); + plugin.unset_group(); + + assert!(plugin.group().is_none()); + } + + mod has_name_only { + use super::*; + + #[test] + fn should_be_true_if_only_a_name_is_set() { + assert!(PluginMetadata::new(BLANK_ESM).unwrap().has_name_only()); + } + + #[test] + fn should_be_false_if_a_group_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_group("group1".into()); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_load_after_files_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_load_after_files(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_requirements_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_requirements(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_incompatibilities_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_incompatibilities(vec![File::new(BLANK_DIFFERENT_ESM.into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_messages_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_messages(vec![Message::new(MessageType::Say, "content1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_tags_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_tags(vec![Tag::new("Relev".into(), TagSuggestion::Addition)]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_dirty_info_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_dirty_info(vec![PluginCleaningData::new(0x12345678, "util1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_clean_info_is_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_clean_info(vec![PluginCleaningData::new(0x12345678, "util1".into())]); + + assert!(!plugin.has_name_only()); + } + + #[test] + fn should_be_false_if_locations_are_set() { + let mut plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + plugin.set_locations(vec![Location::new("url1".into())]); + + assert!(!plugin.has_name_only()); + } + } + + mod is_regex_plugin { + use super::*; + + #[test] + fn should_be_false_for_an_empty_name() { + let plugin = PluginMetadata::new("").unwrap(); + + assert!(!plugin.is_regex_plugin()); + } + + #[test] + fn should_be_false_for_an_exact_plugin_name() { + let plugin = PluginMetadata::new(BLANK_ESM).unwrap(); + + assert!(!plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_colon() { + let plugin = PluginMetadata::new("Blank:.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_backslash() { + let plugin = PluginMetadata::new("Blank\\.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_an_asterisk() { + let plugin = PluginMetadata::new("Blank*.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_question_mark() { + let plugin = PluginMetadata::new("Blank?.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + + #[test] + fn should_be_true_if_the_plugin_name_contains_a_vertical_bar() { + let plugin = PluginMetadata::new("Blank|.esm").unwrap(); + + assert!(plugin.is_regex_plugin()); + } + } + mod as_yaml { use super::*; @@ -441,6 +813,119 @@ mod tests { } } + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_error_if_given_a_scalar() { + let yaml = parse("name1"); + + assert!(PluginMetadata::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(PluginMetadata::try_from(&yaml).is_err()); + } + + #[test] + fn should_store_all_given_data() { + let yaml = parse( + " + name: 'Blank.esp' + after: + - 'Blank.esm' + req: + - 'Blank - Different.esm' + inc: + - 'Blank - Different.esp' + msg: + - type: say + content: 'content' + tag: + - Relev + dirty: + - crc: 0x5 + util: 'utility' + clean: + - crc: 0x6 + util: 'utility' + url: + - 'https://www.example.com'", + ); + + let plugin = PluginMetadata::try_from(&yaml).unwrap(); + + assert_eq!(BLANK_ESP, plugin.name()); + assert_eq!(&[File::new(BLANK_ESM.into())], plugin.load_after_files()); + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESM.into())], + plugin.requirements() + ); + assert_eq!( + &[File::new(BLANK_DIFFERENT_ESP.into())], + plugin.incompatibilities() + ); + assert_eq!( + &[Message::new(MessageType::Say, "content".into())], + plugin.messages() + ); + assert_eq!( + &[Tag::new("Relev".into(), TagSuggestion::Addition)], + plugin.tags() + ); + assert_eq!( + &[PluginCleaningData::new(0x5, "utility".into())], + plugin.dirty_info() + ); + assert_eq!( + &[PluginCleaningData::new(0x6, "utility".into())], + plugin.clean_info() + ); + assert_eq!( + &[Location::new("https://www.example.com".into())], + plugin.locations() + ); + } + + #[test] + fn should_not_error_if_regex_metadata_contains_dirty_or_clean_info() { + let yaml = parse( + " + name: 'Blank\\.esp' + dirty: + - crc: 0x5 + util: 'utility' + clean: + - crc: 0x6 + util: 'utility'", + ); + + let plugin = PluginMetadata::try_from(&yaml).unwrap(); + + assert_eq!("Blank\\.esp", plugin.name()); + assert_eq!( + &[PluginCleaningData::new(0x5, "utility".into())], + plugin.dirty_info() + ); + assert_eq!( + &[PluginCleaningData::new(0x6, "utility".into())], + plugin.clean_info() + ); + } + + #[test] + fn should_error_if_regex_name_is_invalid() { + let yaml = parse("{name: 'RagnvaldBook(Farengar(+Ragnvald)?)?\\.esp'}"); + + assert!(PluginMetadata::try_from(&yaml).is_err()); + } + } + mod emit_yaml { use super::*; use crate::metadata::{MessageType, TagSuggestion, emit}; diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index a689337d..e6fcbf0e 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -131,6 +131,77 @@ impl EmitYaml for Tag { mod tests { use super::*; + mod try_from_yaml { + use crate::metadata::parse; + + use super::*; + + #[test] + fn should_only_set_name_and_suggestion_if_decoding_from_scalar() { + let yaml = parse("Relev"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert!(tag.condition().is_none()); + } + + #[test] + fn should_only_set_name_and_suggestion_if_decoding_from_scalar_with_leading_hyphen() { + let yaml = parse("-Relev"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(!tag.is_addition()); + assert!(tag.condition().is_none()); + } + + #[test] + fn should_error_if_given_a_list() { + let yaml = parse("[0, 1, 2]"); + + assert!(Tag::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_name_is_missing() { + let yaml = parse("{condition: 'file(\"Foo.esp\")'}"); + + assert!(Tag::try_from(&yaml).is_err()); + } + + #[test] + fn should_error_if_given_an_invalid_condition() { + let yaml = parse("{name: Relev, condition: invalid}"); + + assert!(Tag::try_from(&yaml).is_err()); + } + + #[test] + fn should_set_all_fields() { + let yaml = parse("{name: Relev, condition: 'file(\"Foo.esp\")'}"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert_eq!("file(\"Foo.esp\")", tag.condition().unwrap()); + } + + #[test] + fn should_leave_optional_fields_empty_if_not_present() { + let yaml = parse("{name: Relev}"); + + let tag = Tag::try_from(&yaml).unwrap(); + + assert_eq!("Relev", tag.name()); + assert!(tag.is_addition()); + assert!(tag.condition().is_none()); + } + } + mod emit_yaml { use crate::metadata::emit; diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 4ecfbc4d..c81752e0 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -14,7 +14,7 @@ use fancy_regex::Regex; use crate::{ GameType, - archive::{assets_in_archives, find_associated_archives}, + archive::{assets_in_archives, do_assets_overlap, find_associated_archives}, game::GameCache, logging, metadata::plugin_metadata::trim_dot_ghost, @@ -272,29 +272,7 @@ impl Plugin { } pub(crate) fn do_assets_overlap(&self, plugin: &Plugin) -> bool { - let mut assets_iter = self.archive_assets.iter(); - let mut other_assets_iter = plugin.archive_assets.iter(); - - let mut assets = assets_iter.next(); - let mut other_assets = other_assets_iter.next(); - while let (Some((folder, files)), Some((other_folder, other_files))) = - (assets, other_assets) - { - if folder < other_folder { - assets = assets_iter.next(); - } else if folder > other_folder { - other_assets = other_assets_iter.next(); - } else if files.intersection(other_files).next().is_some() { - return true; - } else { - // The folder hashes are equal but they don't contain any of the same - // file hashes, move on to the next folder. It doesn't matter which - // iterator gets incremented. - assets = assets_iter.next(); - } - } - - false + do_assets_overlap(&self.archive_assets, &plugin.archive_assets) } pub(crate) fn resolve_record_ids( @@ -480,3 +458,895 @@ fn extract_version(description: &str) -> Result, Box &'static str { + if game_type == GameType::Starfield { + BLANK_FULL_ESM + } else { + BLANK_ESM + } + } + + fn blank_master_dependent_esm(game_type: GameType) -> &'static str { + if game_type == GameType::Starfield { + "Blank - Override.full.esm" + } else { + BLANK_MASTER_DEPENDENT_ESM + } + } + + #[apply(all_game_types)] + fn new_should_trim_ghost_extension_unless_game_is_openmw(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + let source_path = source_plugins_path(game_type).join(BLANK_ESP); + let ghosted_path = tmp_dir.path().join(BLANK_ESP.to_owned() + ".ghost"); + + std::fs::copy(source_path, &ghosted_path).unwrap(); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &ghosted_path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + if game_type == GameType::OpenMW { + assert_eq!(BLANK_ESP.to_owned() + ".ghost", plugin.name()); + } else { + assert_eq!(BLANK_ESP, plugin.name()); + } + } + + #[test] + fn new_should_handle_non_ascii_filenames_correctly() { + let tmp_dir = tempdir().unwrap(); + let source_path = source_plugins_path(GameType::TES4).join(BLANK_ESM); + let path = tmp_dir.path().join(NON_ASCII_ESM); + + std::fs::copy(source_path, &path).unwrap(); + } + + #[apply(all_game_types)] + fn new_with_header_only_scope_should_read_header_data_only(game_type: GameType) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + let expected_masters = vec![blank_esm(game_type)]; + + assert_eq!(plugin_name, plugin.name()); + assert_eq!(expected_masters, plugin.masters().unwrap()); + assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + assert!(!plugin.is_empty()); + assert!(plugin.version().is_none()); + + match game_type { + GameType::TES3 | GameType::OpenMW => { + assert_eq!(1.2, plugin.header_version().unwrap()) + } + GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), + _ => assert_eq!(0.94, plugin.header_version().unwrap()), + } + + assert!(plugin.crc().is_none()); + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + assert!(!plugin.do_records_overlap(&plugin).unwrap()); + assert_eq!(0, plugin.override_record_count().unwrap()); + } + + #[apply(all_game_types)] + fn new_with_header_only_scope_should_read_version_from_header_description( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(blank_esm(game_type)); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert_eq!("5.0", plugin.version().unwrap()); + } + + #[apply(all_game_types)] + fn new_with_header_only_scope_should_not_read_assets(game_type: GameType) { + let path = source_plugins_path(game_type).join(blank_esm(game_type)); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + } + + #[apply(all_game_types)] + fn new_with_whole_plugin_scope_should_read_records(game_type: GameType) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + let expected_masters = vec![blank_esm(game_type)]; + + assert_eq!(plugin_name, plugin.name()); + assert_eq!(expected_masters, plugin.masters().unwrap()); + assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + assert!(!plugin.is_empty()); + assert!(plugin.version().is_none()); + + match game_type { + GameType::TES3 | GameType::OpenMW => { + assert_eq!(1.2, plugin.header_version().unwrap()) + } + GameType::TES4 => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), + _ => assert_eq!(0.94, plugin.header_version().unwrap()), + } + + let expected_crc = match game_type { + GameType::TES3 | GameType::OpenMW => 3317676987, + GameType::Starfield => 1422425298, + GameType::TES4 => 3759349588, + _ => 3000242590, + }; + + assert_eq!(expected_crc, plugin.crc().unwrap()); + assert!(!plugin.do_assets_overlap(&plugin)); + assert_eq!(0, plugin.asset_count()); + + if matches!(game_type, GameType::TES3 | GameType::OpenMW) { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + + assert_eq!(4, plugin.override_record_count().unwrap()); + } else if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + + assert_eq!(1, plugin.override_record_count().unwrap()); + } else { + assert_eq!(4, plugin.override_record_count().unwrap()); + } + assert!(plugin.do_records_overlap(&plugin).unwrap()); + } + + #[apply(all_game_types)] + fn new_with_whole_plugin_scope_should_read_assets(game_type: GameType) { + let data_path = source_plugins_path(game_type); + let path = data_path.join(BLANK_ESP); + + let mut cache = GameCache::default(); + cache.set_archive_paths(vec![ + data_path.join("Blank.bsa"), + data_path.join("Blank - Main.ba2"), + ]); + + let plugin = Plugin::new(game_type, &cache, &path, LoadScope::WholePlugin).unwrap(); + + if matches!( + game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + // The Starfield test data doesn't include a BA2 file. + assert!(!plugin.loads_archive()); + assert_eq!(0, plugin.asset_count()); + assert!(!plugin.do_assets_overlap(&plugin)); + } else { + assert!(plugin.loads_archive()); + assert_eq!(1, plugin.asset_count()); + assert!(plugin.do_assets_overlap(&plugin)); + } + } + + #[apply(all_game_types)] + fn new_with_whole_plugin_scope_should_succeed_for_openmw_plugins(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + + let data_path = source_plugins_path(game_type); + let omwgame = tmp_dir.path().join("Blank.omwgame"); + let omwaddon = tmp_dir.path().join("Blank.omwaddon"); + let omwscripts = tmp_dir.path().join("Blank.omwscripts"); + + std::fs::copy(data_path.join(blank_esm(game_type)), &omwgame).unwrap(); + std::fs::copy(data_path.join(BLANK_ESP), &omwaddon).unwrap(); + let _ = File::create(&omwscripts).unwrap(); + + assert!( + Plugin::new( + game_type, + &GameCache::default(), + &omwgame, + LoadScope::WholePlugin + ) + .is_ok() + ); + assert!( + Plugin::new( + game_type, + &GameCache::default(), + &omwaddon, + LoadScope::WholePlugin + ) + .is_ok() + ); + + assert_eq!( + game_type == GameType::OpenMW, + Plugin::new( + game_type, + &GameCache::default(), + &omwscripts, + LoadScope::WholePlugin + ) + .is_ok() + ); + } + + #[test] + fn new_should_error_if_plugin_does_not_exist() { + let path = Path::new("missing.esp"); + assert!(!path.exists()); + + assert!( + Plugin::new( + GameType::TES4, + &GameCache::default(), + path, + LoadScope::HeaderOnly + ) + .is_err() + ); + } + + #[apply(all_game_types)] + fn is_master_should_be_false_for_a_non_master_plugin(game_type: GameType) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_master()); + } + + #[apply(all_game_types)] + fn is_light_plugin_should_be_true_for_a_plugin_with_esl_extension_for_fo4_and_later( + game_type: GameType, + ) { + let tmp_dir = tempdir().unwrap(); + let data_path = source_plugins_path(game_type); + + let light_path = tmp_dir.path().join(BLANK_ESL); + std::fs::copy(data_path.join(BLANK_ESP), &light_path).unwrap(); + + let master = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blank_esm(game_type)), + LoadScope::HeaderOnly, + ) + .unwrap(); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let light = Plugin::new( + game_type, + &GameCache::default(), + &light_path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!master.is_light_plugin()); + assert!(!plugin.is_light_plugin()); + + if matches!( + game_type, + GameType::FO4 + | GameType::FO4VR + | GameType::TES5SE + | GameType::TES5VR + | GameType::Starfield + ) { + assert!(light.is_light_plugin()); + } else { + assert!(!light.is_light_plugin()); + } + } + + #[apply(all_game_types)] + fn is_medium_plugin_should_be_true_for_a_medium_flagged_plugin_for_starfield( + game_type: GameType, + ) { + let tmp_dir = tempdir().unwrap(); + + let data_path = source_plugins_path(game_type); + let path = tmp_dir.path().join(BLANK_MEDIUM_ESM); + if game_type == GameType::Starfield { + std::fs::copy(data_path.join(BLANK_MEDIUM_ESM), &path).unwrap(); + } else { + std::fs::copy(data_path.join(BLANK_ESM), &path).unwrap(); + + let mut file = std::fs::File::options().write(true).open(&path).unwrap(); + file.seek(std::io::SeekFrom::Start(9)).unwrap(); + file.write_all(&[0x4]).unwrap(); + } + + let master = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blank_esm(game_type)), + LoadScope::HeaderOnly, + ) + .unwrap(); + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!master.is_medium_plugin()); + assert_eq!(game_type == GameType::Starfield, plugin.is_medium_plugin()); + } + + #[apply(all_game_types)] + fn is_update_plugin_should_be_true_for_an_update_plugin_for_starfield(game_type: GameType) { + let tmp_dir = tempdir().unwrap(); + + let source_name = if game_type == GameType::Starfield { + BLANK_OVERRIDE_ESP + } else { + BLANK_MASTER_DEPENDENT_ESP + }; + let data_path = source_plugins_path(game_type); + let path = tmp_dir.path().join("Blank - Update.esp"); + std::fs::copy(data_path.join(source_name), &path).unwrap(); + + let mut file = std::fs::File::options().write(true).open(&path).unwrap(); + file.seek(std::io::SeekFrom::Start(9)).unwrap(); + file.write_all(&[0x2]).unwrap(); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let update = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_update_plugin()); + assert_eq!(game_type == GameType::Starfield, update.is_update_plugin()); + } + + #[apply(all_game_types)] + fn is_blueprint_plugin_should_be_true_for_a_blueprint_plugin_for_starfield( + game_type: GameType, + ) { + let blueprint_plugin_name = if game_type == GameType::Starfield { + BLANK_OVERRIDE_ESP + } else { + BLANK_MASTER_DEPENDENT_ESP + }; + let data_path = source_plugins_path(game_type); + + let plugin = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_ESP), + LoadScope::HeaderOnly, + ) + .unwrap(); + let update = Plugin::new( + game_type, + &GameCache::default(), + &data_path.join(blueprint_plugin_name), + LoadScope::HeaderOnly, + ) + .unwrap(); + + assert!(!plugin.is_update_plugin()); + assert_eq!(game_type == GameType::Starfield, update.is_update_plugin()); + } + + #[apply(all_game_types)] + fn is_valid_as_light_plugin_should_be_true_only_for_a_skyrim_fallout4_or_starfield_plugin_with_new_formids_in_the_valid_range( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_light_plugin().unwrap(); + + if matches!( + game_type, + GameType::FO4 + | GameType::FO4VR + | GameType::TES5SE + | GameType::TES5VR + | GameType::Starfield + ) { + assert!(result); + } else { + assert!(!result); + } + } + + #[apply(all_game_types)] + fn is_valid_as_medium_plugin_should_be_true_only_for_a_starfield_plugin_with_new_formids_in_the_valid_range( + game_type: GameType, + ) { + let path = source_plugins_path(game_type).join(BLANK_ESP); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_medium_plugin().unwrap(); + + if game_type == GameType::Starfield { + assert!(result); + } else { + assert!(!result); + } + } + + #[apply(all_game_types)] + fn is_valid_as_update_plugin_should_be_true_only_for_a_starfield_plugin_with_no_new_records( + game_type: GameType, + ) { + let plugin_name = blank_master_dependent_esm(game_type); + let path = source_plugins_path(game_type).join(plugin_name); + let mut plugin = Plugin::new( + game_type, + &GameCache::default(), + &path, + LoadScope::WholePlugin, + ) + .unwrap(); + + if game_type == GameType::Starfield { + let master = Plugin::new( + game_type, + &GameCache::default(), + &source_plugins_path(game_type).join(BLANK_FULL_ESM), + LoadScope::WholePlugin, + ) + .unwrap(); + + let metadata = plugins_metadata(&[master]).unwrap(); + + plugin.resolve_record_ids(&metadata).unwrap(); + } + + let result = plugin.is_valid_as_update_plugin().unwrap(); + + if game_type == GameType::Starfield { + assert!(result); + } else { + assert!(!result); + } + } + } + + mod has_plugin_file_extension { + use rstest_reuse::apply; + + use crate::tests::all_game_types; + + use super::*; + + #[apply(all_game_types)] + fn should_be_true_if_file_ends_in_dot_esp_or_dot_esm(game_type: GameType) { + assert!(has_plugin_file_extension(game_type, Path::new("file.esp"))); + assert!(has_plugin_file_extension(game_type, Path::new("file.esm"))); + assert!(!has_plugin_file_extension(game_type, Path::new("file.bsa"))); + } + + #[apply(all_game_types)] + fn should_be_true_if_file_ends_in_dot_esl_and_game_is_fo4_or_later(game_type: GameType) { + let result = has_plugin_file_extension(game_type, Path::new("file.esl")); + if matches!( + game_type, + GameType::FO4 + | GameType::TES5SE + | GameType::FO4VR + | GameType::TES5VR + | GameType::Starfield + ) { + assert!(result); + } else { + assert!(!result); + } + } + + #[apply(all_game_types)] + fn should_trim_ghost_extension_unless_game_is_openmw(game_type: GameType) { + if game_type == GameType::OpenMW { + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.esp.ghost") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.esm.ghost") + )); + } else { + assert!(has_plugin_file_extension( + game_type, + Path::new("file.esp.ghost") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.esm.ghost") + )); + } + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.bsa.ghost") + )); + } + + #[apply(all_game_types)] + fn should_recognise_openmw_plugin_extensions(game_type: GameType) { + if game_type == GameType::OpenMW { + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwgame") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwaddon") + )); + assert!(has_plugin_file_extension( + game_type, + Path::new("file.omwscripts") + )); + } else { + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwgame") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwaddon") + )); + assert!(!has_plugin_file_extension( + game_type, + Path::new("file.omwscripts") + )); + } + } + } + + #[test] + fn extract_bash_tags_should_extract_tags_from_plugin_description_text() { + let text = "Unofficial Skyrim Special Edition Patch + +A comprehensive bugfixing mod for The Elder Scrolls V: Skyrim - Special Edition + +Version: 4.1.4 + +Requires Skyrim Special Edition 1.5.39 or greater. + +{{BASH:C.Climate,C.Encounter,C.ImageSpace,C.Light,C.Location,C.Music,C.Name,C.Owner,C.Water,Delev,Graphics,Invent,Names,Relev,Sound,Stats}}"; + + let tags = extract_bash_tags(text); + + assert_eq!( + vec![ + "C.Climate".to_string(), + "C.Encounter".into(), + "C.ImageSpace".into(), + "C.Light".into(), + "C.Location".into(), + "C.Music".into(), + "C.Name".into(), + "C.Owner".into(), + "C.Water".into(), + "Delev".into(), + "Graphics".into(), + "Invent".into(), + "Names".into(), + "Relev".into(), + "Sound".into(), + "Stats".into(), + ], + tags + ); + } + + mod extract_version { + use crate::plugin::extract_version; + + #[test] + fn should_extract_a_version_containing_a_single_digit() { + assert_eq!("5", extract_version("5").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_containing_multiple_digits() { + assert_eq!("10", extract_version("10").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_containing_multiple_numbers() { + assert_eq!( + "10.11.12.13", + extract_version("10.11.12.13").unwrap().unwrap() + ); + } + + #[test] + fn should_extract_a_semantic_version() { + assert_eq!( + "1.0.0-x.7.z.92", + extract_version("1.0.0-x.7.z.92+exp.sha.5114f85") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_pseudosem_extended_version_stopping_at_the_first_space_separator() { + assert_eq!( + "01.0.0_alpha:1-2", + extract_version("01.0.0_alpha:1-2 3").unwrap().unwrap() + ); + } + + #[test] + fn should_extract_a_version_substring() { + assert_eq!("5.0", extract_version("v5.0").unwrap().unwrap()); + } + + #[test] + fn should_return_none_if_the_string_contains_no_version() { + assert!( + extract_version("The quick brown fox jumped over the lazy dog.") + .unwrap() + .is_none() + ); + } + + #[test] + fn should_extract_a_timestamp_with_forwardslash_date_separators() { + // Found in a Bashed Patch. Though the timestamp isn't useful to + // LOOT, it is semantically a version, and extracting it is far + // easier than trying to skip it and the number of records changed. + assert_eq!( + "10/09/2016 13:15:18", + extract_version("Updated: 10/09/2016 13:15:18\r\n\r\nRecords Changed: 43") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_not_extract_trailing_periods() { + // Found in . + assert_eq!("0.2", extract_version("Version 0.2.").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_following_text_and_a_version_colon_string() { + // Found in . + assert_eq!( + "3.0.0", + extract_version("Legendary Edition\r\n\r\nVersion: 3.0.0") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_ignore_numbers_containing_commas() { + // Found in . + assert_eq!( + "3.5.3", + extract_version("fixing over 2,300 bugs so far! Version: 3.5.3") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_before_text() { + // Found in . + assert_eq!( + "2.1", + extract_version("Version: 2.1 The Unofficial Fallout 3 Patch") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_with_a_preceding_v() { + // Found in . + assert_eq!( + "2.11", + extract_version("V2.11\r\n\r\n{{BASH:Invent}}") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_extract_a_version_preceded_by_colon_period_whitespace() { + // Found in . + assert_eq!("1.09", extract_version("Version:. 1.09").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_with_letters_immediately_after_numbers() { + // Found in . + assert_eq!("2.1.3b", extract_version("comprehensive bugfixing mod for The Elder Scrolls V: Skyrim\r\n\r\nVersion: 2.1.3b\r\n\r\n").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_with_period_and_no_preceding_identifier() { + // Found in . + assert_eq!("5.1", extract_version("SkyUI 5.1").unwrap().unwrap()); + } + + #[test] + fn should_not_extract_a_single_digit_in_a_sentence() { + // Found in . + assert!( + extract_version( + "Adds 8 variants of Triss Merigold's outfit from \"The Witcher 2\"" + ) + .unwrap() + .is_none() + ); + } + + #[test] + fn should_prefer_version_prefixed_numbers_over_versions_in_sentence() { + // Found in + assert_eq!("2.0.0", extract_version("Requires Skyrim patch 1.9.32.0.8 or greater.\nRequires Unofficial Skyrim Legendary Edition Patch 3.0.0 or greater.\nVersion 2.0.0").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_that_is_a_single_digit_preceded_by_v() { + // Found in + assert_eq!( + "8", + extract_version("Immersive Armors v8 Main Plugin") + .unwrap() + .unwrap() + ); + } + + #[test] + fn should_prefer_version_prefixed_numbers_over_v_prefixed_number() { + // Found in + assert_eq!("1.0", extract_version("Compatibility patch for AOS v2.5 and True Storms v1.5 (or later),\nPatch Version: 1.0").unwrap().unwrap()); + } + + #[test] + fn should_extract_a_version_that_is_a_single_digit_after_version_colon_space() { + // Found in + assert_eq!( + "2", + extract_version("Version: 2 {{BASH:C.Water}}") + .unwrap() + .unwrap() + ); + } + } +} diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs index a4f9d7d5..373db7cc 100644 --- a/src/sorting/groups.rs +++ b/src/sorting/groups.rs @@ -267,3 +267,213 @@ pub fn get_default_group_node(graph: &GroupsGraph) -> Result { + assert_eq!("a", e.into_group_name()) + } + _ => panic!("Expected an undefined group error"), + } + } + + #[test] + fn should_error_if_masterlist_group_loads_after_user_group() { + let masterlist = &[Group::new("b".into()).with_after_groups(vec!["a".into()])]; + let userlist = &[Group::new("a".into())]; + + match build_groups_graph(masterlist, userlist) { + Err(BuildGroupsGraphError::UndefinedGroup(e)) => { + assert_eq!("a", e.into_group_name()) + } + _ => panic!("Expected an undefined group error"), + } + } + + #[test] + fn should_error_if_after_groups_are_cyclic() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let userlist = &[ + Group::new("a".into()).with_after_groups(vec!["c".into()]), + Group::new("c".into()).with_after_groups(vec!["b".into()]), + ]; + + match build_groups_graph(masterlist, userlist) { + Err(BuildGroupsGraphError::CycleFound(e)) => { + let cycle = e.into_cycle(); + + assert_eq!( + &[ + Vertex::new("a".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("c".into()).with_out_edge_type(EdgeType::UserLoadAfter), + ], + cycle.as_slice() + ); + } + _ => panic!("Expected a cyclic interaction error"), + } + } + + #[test] + fn cyclic_interaction_error_should_only_include_groups_that_are_part_of_the_cycle() { + let masterlist = &[ + Group::new("a".into()).with_after_groups(vec!["b".into()]), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["b".into()]), + ]; + + match build_groups_graph(masterlist, &[]) { + Err(BuildGroupsGraphError::CycleFound(e)) => { + let cycle = e.into_cycle(); + + assert_eq!( + &[ + Vertex::new("a".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + ], + cycle.as_slice() + ); + } + _ => panic!("Expected a cyclic interaction error"), + } + } + } + + mod find_path { + use super::*; + + #[test] + fn should_error_if_the_from_group_does_not_exist() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + assert!(find_path(&graph, "c", "a").is_err()); + } + + #[test] + fn should_error_if_the_to_group_does_not_exist() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + assert!(find_path(&graph, "a", "c").is_err()); + } + + #[test] + fn should_return_an_empty_vec_if_there_is_no_path() { + let masterlist = &[Group::new("a".into()), Group::new("b".into())]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "b").unwrap(); + + assert!(path.is_empty()); + } + + #[test] + fn should_find_the_shortest_path_if_there_is_no_user_metadata() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["c".into()]), + Group::new("e".into()).with_after_groups(vec!["b".into(), "d".into()]), + ]; + let graph = build_groups_graph(masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + + #[test] + fn should_find_the_shortest_path_involving_user_metadata_if_there_no_user_metadata() { + let masterlist = &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("e".into()).with_after_groups(vec!["b".into()]), + ]; + let userlist = &[ + Group::new("d".into()).with_after_groups(vec!["c".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ]; + let graph = build_groups_graph(masterlist, userlist).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("c".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("d".into()).with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + + #[test] + fn should_not_depend_on_the_after_group_definition_order() { + let masterlists = &[ + &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["b".into(), "c".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ], + &[ + Group::new("a".into()), + Group::new("b".into()).with_after_groups(vec!["a".into()]), + Group::new("c".into()).with_after_groups(vec!["a".into()]), + Group::new("d".into()).with_after_groups(vec!["c".into(), "b".into()]), + Group::new("e".into()).with_after_groups(vec!["d".into()]), + ], + ]; + + for masterlist in masterlists { + let graph = build_groups_graph(*masterlist, &[]).unwrap(); + + let path = find_path(&graph, "a", "e").unwrap(); + assert_eq!( + &[ + Vertex::new("a".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("b".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("d".into()).with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new("e".into()) + ], + path.as_slice() + ); + } + } + } +} diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs index 682cb2fd..54b033dc 100644 --- a/src/sorting/mod.rs +++ b/src/sorting/mod.rs @@ -4,3 +4,78 @@ pub mod groups; pub mod plugins; mod validate; pub mod vertex; + +#[cfg(test)] +mod test { + use super::plugins::SortingPlugin; + use crate::error::PluginDataError; + + #[derive(Default)] + pub struct TestPlugin { + name: String, + masters: Vec, + pub is_master: bool, + pub is_blueprint_plugin: bool, + pub override_record_count: usize, + pub asset_count: usize, + overlapping_record_plugins: Vec, + overlapping_asset_plugins: Vec, + } + + impl TestPlugin { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + ..Default::default() + } + } + + pub fn add_master(&mut self, plugin_name: &str) { + self.masters.push(plugin_name.to_string()); + } + + pub fn add_overlapping_records(&mut self, plugin_name: &str) { + self.overlapping_record_plugins + .push(plugin_name.to_string()); + } + + pub fn add_overlapping_assets(&mut self, plugin_name: &str) { + self.overlapping_asset_plugins.push(plugin_name.to_string()); + } + } + + #[cfg(test)] + impl SortingPlugin for TestPlugin { + fn name(&self) -> &str { + &self.name + } + + fn is_master(&self) -> bool { + self.is_master + } + + fn is_blueprint_plugin(&self) -> bool { + self.is_blueprint_plugin + } + + fn masters(&self) -> Result, PluginDataError> { + Ok(self.masters.clone()) + } + + fn override_record_count(&self) -> Result { + Ok(self.override_record_count) + } + + fn asset_count(&self) -> usize { + self.asset_count + } + + fn do_records_overlap(&self, other: &Self) -> Result { + Ok(self.overlapping_record_plugins.contains(&other.name)) + } + + fn do_assets_overlap(&self, other: &Self) -> bool { + self.overlapping_asset_plugins.contains(&other.name) + } + } +} diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs index 34459f6f..856aad01 100644 --- a/src/sorting/plugins.rs +++ b/src/sorting/plugins.rs @@ -164,8 +164,8 @@ impl<'a, T: SortingPlugin> PluginsGraph<'a, T> { PluginsGraph::default() } - fn add_node(&mut self, plugin: PluginSortingData<'a, T>) { - self.inner.add_node(Rc::new(plugin)); + fn add_node(&mut self, plugin: PluginSortingData<'a, T>) -> NodeIndex { + self.inner.add_node(Rc::new(plugin)) } fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge_type: EdgeType) { @@ -1178,3 +1178,2567 @@ impl<'e, T: SortingPlugin> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, self.edge_stack.pop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + use crate::sorting::{groups::build_groups_graph, test::TestPlugin}; + + const PLUGIN_A: &str = "A.esp"; + const PLUGIN_B: &str = "B.esp"; + + struct Fixture { + groups_graph: GroupsGraph, + plugins: HashMap, + } + + impl Fixture { + fn with_plugins(plugin_names: &[&str]) -> Self { + let masterlist = &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::new("default".into()).with_after_groups(vec!["C".into()]), + Group::new("E".into()).with_after_groups(vec!["default".into()]), + Group::new("F".into()).with_after_groups(vec!["E".into()]), + ]; + let userlist = &[Group::new("C".into()).with_after_groups(vec!["B".into()])]; + let groups_graph = build_groups_graph(masterlist, userlist).unwrap(); + + Self { + groups_graph, + plugins: plugin_names + .iter() + .enumerate() + .map(|(i, n)| (n.to_string(), (TestPlugin::new(n), i))) + .collect(), + } + } + + fn get_plugin(&self, name: &str) -> &(TestPlugin, usize) { + self.plugins.get(name).unwrap() + } + + fn get_plugin_mut(&mut self, name: &str) -> &mut TestPlugin { + &mut self.plugins.get_mut(name).unwrap().0 + } + + fn sorting_data<'a>(&'a self, name: &str) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + PluginSortingData::new(plugin, None, None, *index).unwrap() + } + + fn group_sorting_data<'a>( + &'a self, + name: &str, + group_name: &str, + ) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + let mut metadata = PluginMetadata::new(name).unwrap(); + metadata.set_group(group_name.into()); + + PluginSortingData::new(plugin, Some(&metadata), None, *index).unwrap() + } + + fn user_group_sorting_data<'a>( + &'a self, + name: &str, + group_name: &str, + ) -> PluginSortingData<'a, TestPlugin> { + let (plugin, index) = self.get_plugin(name); + + let mut metadata = PluginMetadata::new(name).unwrap(); + metadata.set_group(group_name.into()); + + PluginSortingData::new(plugin, None, Some(&metadata), *index).unwrap() + } + } + + mod plugin_sorting_data { + use crate::tests::BLANK_ESM; + + use super::*; + + #[test] + fn is_blueprint_master_should_be_true_if_a_plugin_is_a_master_and_a_blueprint_plugin() { + let mut master = TestPlugin::new(BLANK_ESM); + master.is_master = true; + let mut blueprint_plugin = TestPlugin::new(BLANK_ESM); + blueprint_plugin.is_blueprint_plugin = true; + let mut blueprint_master = TestPlugin::new(BLANK_ESM); + blueprint_master.is_master = true; + blueprint_master.is_blueprint_plugin = true; + + let plugin = PluginSortingData::new(&master, None, None, 0).unwrap(); + assert!(!plugin.is_blueprint_master()); + + let plugin = PluginSortingData::new(&blueprint_plugin, None, None, 0).unwrap(); + assert!(!plugin.is_blueprint_master()); + + let plugin = PluginSortingData::new(&blueprint_master, None, None, 0).unwrap(); + assert!(plugin.is_blueprint_master()); + } + } + + mod plugins_graph { + use super::*; + + use crate::Vertex; + + const PLUGIN_C: &str = "C.esp"; + const PLUGIN_D: &str = "D.esp"; + const PLUGIN_E: &str = "E.esp"; + + fn edge_type( + graph: &PluginsGraph<'_, TestPlugin>, + from: NodeIndex, + to: NodeIndex, + ) -> EdgeType { + *graph + .inner + .edge_weight(graph.inner.find_edge(from, to).unwrap()) + .unwrap() + } + + mod check_for_cycles { + use super::*; + + #[test] + fn should_succeed_if_there_is_no_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_edge(a, b, EdgeType::Master); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_error_if_there_is_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_edge(a, b, EdgeType::Master); + graph.add_edge(b, a, EdgeType::Master); + + let cycle = graph.check_for_cycles().unwrap_err().into_cycle(); + + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + ], + cycle.as_slice() + ); + } + + #[test] + fn should_only_give_plugins_that_are_part_of_the_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + + graph.add_edge(a, b, EdgeType::Master); + graph.add_edge(b, c, EdgeType::Master); + graph.add_edge(b, a, EdgeType::MasterFlag); + + let cycle = graph.check_for_cycles().unwrap_err().into_cycle(); + + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + cycle.as_slice() + ); + } + } + + #[test] + fn topological_sort_should_return_empty_list_if_there_are_no_plugins() { + let graph = PluginsGraph::::new(); + let sorted = graph.topological_sort().unwrap(); + + assert!(sorted.is_empty()); + } + + mod add_early_loading_plugin_edges { + use super::*; + + #[test] + fn should_add_no_edges_if_there_are_no_early_loading_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + + graph.add_early_loading_plugin_edges(&[]); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(c, b)); + } + + #[test] + fn should_add_edges_between_consecutive_early_loaders_skipping_missing_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_early_loading_plugin_edges(&[ + PLUGIN_A.into(), + PLUGIN_B.into(), + PLUGIN_C.into(), + PLUGIN_D.into(), + ]); + + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(a, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_from_only_the_last_installed_early_loader_to_all_non_early_loader_plugins() + { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_early_loading_plugin_edges(&[ + PLUGIN_A.into(), + PLUGIN_B.into(), + PLUGIN_C.into(), + ]); + + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, e)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(a, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + mod add_group_edges { + use super::*; + + const PLUGIN_A1: &str = "A1.esp"; + const PLUGIN_A2: &str = "A2.esp"; + const PLUGIN_B1: &str = "B1.esp"; + const PLUGIN_B2: &str = "B2.esp"; + const PLUGIN_C1: &str = "C1.esp"; + const PLUGIN_C2: &str = "C2.esp"; + const PLUGIN_D1: &str = "D1.esp"; + const PLUGIN_D2: &str = "D2.esp"; + const PLUGIN_D3: &str = "D3.esp"; + const PLUGIN_F: &str = "F.esp"; + + #[test] + fn should_add_user_group_edge_if_source_plugin_is_in_group_due_to_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.user_group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_target_plugin_is_in_group_due_to_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.user_group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_starts_with_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, b, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_ends_with_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_user_group_edge_if_group_path_involves_user_metadata() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::UserGroup, edge_type(&graph, a, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_masterlist_group_edge_if_no_user_metadata_is_involved() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert_eq!(EdgeType::MasterlistGroup, edge_type(&graph, a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_between_plugins_in_indirectly_connected_groups_when_an_intermediate_plugin_edge_is_skipped() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> B1.esp -> A1.esp -> B2.esp -> C1.esp + // -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(b1, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c1)); + assert!(graph.inner.contains_edge(b2, c2)); + assert!(graph.inner.contains_edge(a1, c1)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(!graph.inner.contains_edge(c1, c2)); + assert!(!graph.inner.contains_edge(c2, c1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_empty_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> C.esp + assert!(graph.inner.contains_edge(a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_non_empty_default_group() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> D.esp -> E.esp + // ----------> + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(a, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_an_edge_that_would_cause_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert!(graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(a, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_an_edge_that_would_cause_a_cycle_involving_other_non_default_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + assert!(graph.inner.contains_edge(c, a)); + assert!(graph.inner.contains_edge(a, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_only_edges_to_the_target_group_plugins_that_would_cause_a_cycle() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_C1, PLUGIN_C2]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(c1, a, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be C1.esp -> A.esp -> C2.esp + assert!(graph.inner.contains_edge(c1, a)); + assert!(graph.inner.contains_edge(a, c2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_skip_only_edges_from_ancestors_to_the_target_group_plugins_that_would_cause_a_cycle() + { + let fixture = + Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_D3]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let d3 = graph.add_node(fixture.sorting_data(PLUGIN_D3)); + + graph.add_edge(d1, b, EdgeType::Master); + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(c, b, EdgeType::Master); + graph.add_edge(c, d2, EdgeType::Master); + graph.add_edge(c, d3, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: C.esp -> D2.esp -> B.esp -> D3.esp + // -> D1.esp -> + // --------------------> + // -----------> + assert!(graph.inner.contains_edge(d1, b)); + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(c, b)); + assert!(graph.inner.contains_edge(c, d2)); + assert!(graph.inner.contains_edge(c, d3)); + assert!(graph.inner.contains_edge(b, d3)); + assert!(graph.inner.contains_edge(c, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_plugin_edges_across_a_successor_if_at_least_one_edge_to_the_successor_group_was_skipped_with_successive_depths() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> B1.esp -> A1.esp -> C1.esp -> B2.esp -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b2)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a1, c1)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(b1, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_plugin_edges_across_a_successor_if_at_least_one_edge_to_the_successor_group_was_skipped_with_successive_depths_and_a_different_order() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A2.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(a2, c1)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(b2, c2)); + assert!(!graph.inner.contains_edge(b2, c1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edge_from_ancestor_to_successor_if_none_of_a_groups_plugins_can() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B1, PLUGIN_B2, PLUGIN_C]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_edge(c, b1, EdgeType::Master); + graph.add_edge(c, b2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be A.esp -> C1.esp -> B1.esp + // -> B2.esp + assert!(graph.inner.contains_edge(a, b1)); + assert!(graph.inner.contains_edge(a, b2)); + assert!(graph.inner.contains_edge(c, b1)); + assert!(graph.inner.contains_edge(c, b2)); + assert!(graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(b1, b2)); + assert!(!graph.inner.contains_edge(b2, b1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edge_from_ancestor_to_successor_if_none_of_a_groups_plugins_can_with_edges_across_the_skipped_group() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A1, PLUGIN_A2, PLUGIN_B1, PLUGIN_B2, PLUGIN_C1, PLUGIN_C2, PLUGIN_D1, + PLUGIN_D2, + ]); + + let mut graph = PluginsGraph::::new(); + let a1 = graph.add_node(fixture.group_sorting_data(PLUGIN_A1, "A")); + let a2 = graph.add_node(fixture.group_sorting_data(PLUGIN_A2, "A")); + let b1 = graph.add_node(fixture.group_sorting_data(PLUGIN_B1, "B")); + let b2 = graph.add_node(fixture.group_sorting_data(PLUGIN_B2, "B")); + let c1 = graph.add_node(fixture.group_sorting_data(PLUGIN_C1, "C")); + let c2 = graph.add_node(fixture.group_sorting_data(PLUGIN_C2, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + + graph.add_edge(b1, a1, EdgeType::Master); + graph.add_edge(c1, b1, EdgeType::Master); + graph.add_edge(d1, c1, EdgeType::Master); + graph.add_edge(d2, c1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: + // A2.esp -> D1.esp -> C1.esp -> B1.esp -> A1.esp -> B2.esp -> C2.esp + // -> D2.esp -> + assert!(graph.inner.contains_edge(b1, a1)); + assert!(graph.inner.contains_edge(c1, b1)); + assert!(graph.inner.contains_edge(d1, c1)); + assert!(graph.inner.contains_edge(d2, c1)); + assert!(graph.inner.contains_edge(a1, b2)); + assert!(graph.inner.contains_edge(a2, b1)); + assert!(graph.inner.contains_edge(a2, b2)); + assert!(graph.inner.contains_edge(a1, c2)); + assert!(graph.inner.contains_edge(b1, c2)); + assert!(graph.inner.contains_edge(a2, c1)); + assert!(graph.inner.contains_edge(a2, d1)); + assert!(graph.inner.contains_edge(a2, d2)); + assert!(!graph.inner.contains_edge(b2, c1)); + assert!(!graph.inner.contains_edge(d1, d2)); + assert!(!graph.inner.contains_edge(d2, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_last() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_edge(d, b, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_first() { + let fixture = Fixture::with_plugins(&[PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be E.esp -> F.esp -> D.esp + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d)); + assert!(!graph.inner.contains_edge(d, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_across_skipped_intermediate_groups() + { + let fixture = Fixture::with_plugins(&[PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(e, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be E.esp -> D.esp -> F.esp + assert!(graph.inner.contains_edge(e, d)); + assert!(graph.inner.contains_edge(d, f)); + assert!(!graph.inner.contains_edge(f, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_d1_first_d2_last() { + let fixture = Fixture::with_plugins(&[PLUGIN_D1, PLUGIN_D2, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, d2, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D1.esp -> E.esp -> F.esp -> D2.esp + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d2)); + assert!(graph.inner.contains_edge(d1, e)); + assert!(!graph.inner.contains_edge(d2, d1)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_no_ideal_result() { + let fixture = + Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d, b, EdgeType::Master); + graph.add_edge(f, d, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // No ideal result, expected is F.esp -> D.esp -> B.esp -> C.esp -> E.esp + assert!(graph.inner.contains_edge(f, d)); + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(e, f)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_in_middle_and_d_bookends() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_E, PLUGIN_F, + ]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(f, d1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be D2.esp -> B.esp -> C.esp -> E.esp -> F.esp -> D1.esp + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d1)); + assert!(!graph.inner.contains_edge(d1, d2)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_deprioritise_edges_from_default_group_plugins_with_default_in_middle_and_d_throughout() + { + const PLUGIN_D4: &str = "D4.esp"; + let fixture = Fixture::with_plugins(&[ + PLUGIN_B, PLUGIN_C, PLUGIN_D1, PLUGIN_D2, PLUGIN_D3, PLUGIN_D4, PLUGIN_E, + PLUGIN_F, + ]); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d1 = graph.add_node(fixture.sorting_data(PLUGIN_D1)); + let d2 = graph.add_node(fixture.sorting_data(PLUGIN_D2)); + let d3 = graph.add_node(fixture.sorting_data(PLUGIN_D3)); + let d4 = graph.add_node(fixture.sorting_data(PLUGIN_D4)); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(d2, b, EdgeType::Master); + graph.add_edge(d4, c, EdgeType::Master); + graph.add_edge(f, d1, EdgeType::Master); + + graph.add_group_edges(&fixture.groups_graph).unwrap(); + + // Should be: + // D2.esp -> B.esp -> D4.esp -> C.esp -> D3.esp -> E.esp -> F.esp -> D1.esp + assert!(graph.inner.contains_edge(d2, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, d3)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(d3, e)); + assert!(graph.inner.contains_edge(e, f)); + assert!(graph.inner.contains_edge(f, d1)); + assert!(graph.inner.contains_edge(d4, c)); + assert!(graph.inner.contains_edge(b, d4)); + assert!(!graph.inner.contains_edge(d1, d2)); + assert!(!graph.inner.contains_edge(d1, d3)); + assert!(!graph.inner.contains_edge(d1, d4)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_asymmetric_branches_in_the_groups_graph() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["A".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> C.esp + // -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(d, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_asymmetric_branches_in_the_groups_graph_that_merge() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["A".into()]), + Group::new("E".into()).with_after_groups(vec!["C".into(), "D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> C.esp -> E.esp + // -> D.esp ----------> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(!graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(d, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_branches_in_the_groups_graph_that_form_a_diamond_pattern() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp + // -> C.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_merge_point_of_branches_in_the_groups_graph() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(d, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp -> C.esp -> E.esp + assert!(graph.inner.contains_edge(d, c)); + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_a_groups_graph_with_multiple_successive_branches() { + const PLUGIN_G: &str = "G.esp"; + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::new("F".into()).with_after_groups(vec!["D".into()]), + Group::new("G".into()).with_after_groups(vec!["E".into(), "F".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "F")); + let g = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "G")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be: + // A.esp -> B.esp -> D.esp -> E.esp -> G.esp + // -> C.esp -> -> F.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(d, f)); + assert!(graph.inner.contains_edge(e, g)); + assert!(graph.inner.contains_edge(f, g)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(e, f)); + assert!(!graph.inner.contains_edge(f, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_find_all_groups_in_all_paths_between_two_groups_when_ignoring_a_plugin() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default().with_after_groups(vec!["B".into(), "D".into()]), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_edge(e, a, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be: + // A.esp -> B.esp -> D.esp -> E.esp -> G.esp + // -> C.esp -> -> F.esp -> + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(!graph.inner.contains_edge(a, e)); + assert!(!graph.inner.contains_edge(b, e)); + assert!(!graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(d, e)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_isolated_groups() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp + // C.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_handle_disconnected_group_graphs() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp + // C.esp -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(c, a)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(d, a)); + assert!(!graph.inner.contains_edge(d, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_add_edges_across_the_merge_point_of_two_root_node_paths() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(c, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> C.esp -> D.esp + // B.esp ----------> + assert!(graph.inner.contains_edge(c, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_not_depend_on_group_definition_order_if_there_is_a_single_linear_path() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let masterlists = &[ + [ + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default().with_after_groups(vec!["C".into()]), + ], + [ + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("B".into()), + Group::default().with_after_groups(vec!["C".into()]), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + + graph.add_edge(d, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(d, b)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_group_definition_order_if_there_are_multiple_roots() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let masterlists = &[ + [ + Group::new("A".into()), + Group::new("B".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + [ + Group::new("B".into()), + Group::new("A".into()), + Group::new("C".into()).with_after_groups(vec!["A".into(), "B".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::default(), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(d, a, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be B.esp -> D.esp -> A.esp -> C.esp + // B.esp -------------------> + assert!(graph.inner.contains_edge(d, a)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_branching_group_definition_order() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let masterlists = &[ + [ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + [ + Group::new("A".into()), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["B".into(), "C".into()]), + Group::new("E".into()).with_after_groups(vec!["D".into()]), + Group::default(), + ], + ]; + + for masterlist in masterlists { + let groups_graph = build_groups_graph(masterlist, &[]).unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(e, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> D.esp -> E.esp -> C.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(a, c)); + assert!(graph.inner.contains_edge(a, d)); + assert!(graph.inner.contains_edge(a, e)); + assert!(graph.inner.contains_edge(b, d)); + assert!(graph.inner.contains_edge(b, e)); + assert!(graph.inner.contains_edge(d, e)); + assert!(graph.inner.contains_edge(e, c)); + + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(c, b)); + assert!(!graph.inner.contains_edge(c, d)); + assert!(!graph.inner.contains_edge(c, e)); + assert!(!graph.inner.contains_edge(d, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_not_depend_on_plugin_graph_node_order() { + let fixture = Fixture::with_plugins(&[PLUGIN_A1, PLUGIN_A2, PLUGIN_B, PLUGIN_C]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let a1 = (PLUGIN_A1, "A"); + let a2 = (PLUGIN_A2, "A"); + let b = (PLUGIN_B, "B"); + let c = (PLUGIN_C, "C"); + + let variations = &[ + [a1, a2, b, c], + [a1, a2, c, b], + [a1, b, c, a2], + [a1, b, a2, c], + [a1, c, a2, b], + [a1, c, b, a2], + [a2, a1, b, c], + [a2, a1, c, b], + [a2, b, c, a1], + [a2, b, a1, c], + [a2, c, a1, b], + [a2, c, b, a1], + [b, a2, a1, c], + [b, a2, c, a1], + [b, a1, c, a2], + [b, a1, a2, c], + [b, c, a2, a1], + [b, c, a1, a2], + [c, a2, b, a1], + [c, a2, a1, b], + [c, b, a1, a2], + [c, b, a2, a1], + [c, a1, a2, b], + [c, a1, b, a2], + ]; + + for plugins in variations { + let mut graph = PluginsGraph::::new(); + + for plugin in plugins { + graph.add_node(fixture.group_sorting_data(plugin.0, plugin.1)); + } + + let a1 = graph.node_index_by_name(PLUGIN_A1).unwrap(); + let a2 = graph.node_index_by_name(PLUGIN_A2).unwrap(); + let b = graph.node_index_by_name(PLUGIN_B).unwrap(); + let c = graph.node_index_by_name(PLUGIN_C).unwrap(); + + graph.add_edge(c, a1, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A2.esp -> C.esp -> A1.esp -> B.esp + // A2.esp --------------------> + assert!(graph.inner.contains_edge(c, a1)); + assert!(graph.inner.contains_edge(a1, b)); + assert!(graph.inner.contains_edge(a2, b)); + assert!(graph.inner.contains_edge(a2, c)); + assert!(!graph.inner.contains_edge(a1, a2)); + assert!(!graph.inner.contains_edge(a2, a1)); + assert!(!graph.inner.contains_edge(b, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + #[test] + fn should_start_searching_from_root_groups_before_going_in_lexicographical_order() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D]); + + let groups_graph = build_groups_graph( + &[ + Group::new("D".into()), + Group::new("A".into()).with_after_groups(vec!["D".into()]), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + + graph.add_edge(c, d, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be C.esp -> D.esp -> A.esp -> B.esp + // Processing groups lexicographically would give: + // A.esp -> B.esp -> C.esp -> D.esp + assert!(graph.inner.contains_edge(c, d)); + assert!(graph.inner.contains_edge(d, a)); + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(a, b)); + + assert!(!graph.inner.contains_edge(a, c)); + assert!(!graph.inner.contains_edge(a, d)); + assert!(!graph.inner.contains_edge(b, a)); + assert!(!graph.inner.contains_edge(b, c)); + assert!(!graph.inner.contains_edge(b, d)); + assert!(!graph.inner.contains_edge(d, c)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn should_start_searching_from_the_root_group_with_the_longest_path() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, + ]); + + let groups_graph = build_groups_graph( + &[ + Group::new("D".into()), + Group::new("B".into()).with_after_groups(vec!["D".into()]), + Group::new("C".into()).with_after_groups(vec!["B".into()]), + Group::new("A".into()), + Group::new("E".into()).with_after_groups(vec!["C".into(), "A".into()]), + Group::new("F".into()).with_after_groups(vec!["E".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + let f = graph.add_node(fixture.group_sorting_data(PLUGIN_F, "F")); + + graph.add_edge(f, b, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be D.esp -> B.esp -> C.esp -> E.esp + // A.esp -> F.esp ----------> B.esp + // A.esp -------------------------------------> E.esp + assert!(graph.inner.contains_edge(d, b)); + assert!(graph.inner.contains_edge(b, c)); + assert!(graph.inner.contains_edge(c, e)); + assert!(graph.inner.contains_edge(a, f)); + assert!(graph.inner.contains_edge(a, e)); + assert!(graph.inner.contains_edge(f, b)); + + assert!(graph.check_for_cycles().is_ok()); + } + + #[test] + fn does_not_start_searching_with_the_longest_path() { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let groups_graph = build_groups_graph( + &[ + Group::new("A".into()), + Group::new("B".into()).with_after_groups(vec!["A".into()]), + Group::new("C".into()).with_after_groups(vec!["A".into()]), + Group::new("D".into()).with_after_groups(vec!["C".into()]), + Group::new("E".into()).with_after_groups(vec!["B".into(), "D".into()]), + Group::default(), + ], + &[], + ) + .unwrap(); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.group_sorting_data(PLUGIN_A, "A")); + let b = graph.add_node(fixture.group_sorting_data(PLUGIN_B, "B")); + let c = graph.add_node(fixture.group_sorting_data(PLUGIN_C, "C")); + let d = graph.add_node(fixture.group_sorting_data(PLUGIN_D, "D")); + let e = graph.add_node(fixture.group_sorting_data(PLUGIN_E, "E")); + + graph.add_edge(e, c, EdgeType::Master); + + graph.add_group_edges(&groups_graph).unwrap(); + + // Should be A.esp -> B.esp -> E.esp -> C.esp -> D.esp + assert!(graph.inner.contains_edge(a, b)); + assert!(graph.inner.contains_edge(b, e)); + assert!(graph.inner.contains_edge(e, c)); + assert!(graph.inner.contains_edge(c, d)); + + assert!(graph.check_for_cycles().is_ok()); + } + } + + mod add_overlap_edges { + use super::*; + + #[test] + fn should_not_add_edges_between_non_overlapping_plugins() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edges_between_overlapping_plugins_with_equal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 1; + a.add_overlapping_records(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_overlapping_plugins_with_unequal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + a.add_overlapping_records(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::RecordOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_non_overlapping_plugins_with_unequal_override_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_plugins_with_asset_overlap_and_equal_asset_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 1; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_not_add_edge_between_plugins_with_no_asset_overlap_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert!(!graph.inner.contains_edge(a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_plugins_with_asset_overlap_and_unequal_asset_counts() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_overlapping_plugins_with_asset_overlap_and_equal_override_count_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.asset_count = 2; + a.add_overlapping_records(PLUGIN_B); + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_add_edge_between_plugins_with_asset_overlap_and_unequal_override_count_and_unequal_asset_counts() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 1; + a.asset_count = 2; + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 2; + b.asset_count = 1; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::AssetOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + + #[test] + fn should_choose_record_overlap_over_asset_overlap() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.override_record_count = 2; + a.asset_count = 1; + a.add_overlapping_records(PLUGIN_B); + a.add_overlapping_assets(PLUGIN_B); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.override_record_count = 1; + b.asset_count = 2; + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + + graph.add_overlap_edges().unwrap(); + + assert_eq!(EdgeType::RecordOverlap, edge_type(&graph, a, b)); + assert!(!graph.inner.contains_edge(b, a)); + } + } + + mod add_tie_break_edges { + use super::*; + + const PLUGIN_F: &str = "F.esp"; + const PLUGIN_G: &str = "G.esp"; + const PLUGIN_H: &str = "H.esp"; + const PLUGIN_I: &str = "I.esp"; + const PLUGIN_J: &str = "J.esp"; + + #[test] + fn should_not_error_on_a_graph_with_one_node() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + + assert!(graph.add_tie_break_edges().is_ok()); + } + + #[test] + fn should_result_in_a_sort_order_equal_to_vertex_creation_order_if_there_are_no_other_edges() + { + let fixture = + Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + graph.add_node(fixture.sorting_data(PLUGIN_B)); + graph.add_node(fixture.sorting_data(PLUGIN_C)); + graph.add_node(fixture.sorting_data(PLUGIN_D)); + graph.add_node(fixture.sorting_data(PLUGIN_E)); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + assert_eq!( + &[PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E], + sorted_plugin_names.as_slice() + ); + } + + #[test] + fn should_pin_paths_that_prevent_the_vertex_creation_order_from_being_used() { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, PLUGIN_H, + PLUGIN_I, PLUGIN_J, + ]); + + let mut graph = PluginsGraph::::new(); + graph.add_node(fixture.sorting_data(PLUGIN_A)); + graph.add_node(fixture.sorting_data(PLUGIN_B)); + graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + let e = graph.add_node(fixture.sorting_data(PLUGIN_E)); + let f = graph.add_node(fixture.sorting_data(PLUGIN_F)); + let g = graph.add_node(fixture.sorting_data(PLUGIN_G)); + let h = graph.add_node(fixture.sorting_data(PLUGIN_H)); + let i = graph.add_node(fixture.sorting_data(PLUGIN_I)); + graph.add_node(fixture.sorting_data(PLUGIN_J)); + + // Add a path g -> h -> i -> f + graph.add_edge(g, h, EdgeType::RecordOverlap); + graph.add_edge(h, i, EdgeType::RecordOverlap); + graph.add_edge(i, f, EdgeType::RecordOverlap); + + // Also add g -> d and i -> e + graph.add_edge(g, d, EdgeType::RecordOverlap); + graph.add_edge(i, e, EdgeType::RecordOverlap); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + assert_eq!( + &[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_G, PLUGIN_D, PLUGIN_H, PLUGIN_I, + PLUGIN_E, PLUGIN_F, PLUGIN_J + ], + sorted_plugin_names.as_slice() + ); + } + + #[test] + fn should_prefix_path_to_new_load_order_if_the_first_pair_of_nodes_cannot_be_used_in_creation_order() + { + let fixture = Fixture::with_plugins(&[ + PLUGIN_A, PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_E, PLUGIN_F, PLUGIN_G, PLUGIN_H, + PLUGIN_I, PLUGIN_J, + ]); + + let mut graph = PluginsGraph::::new(); + let a = graph.add_node(fixture.sorting_data(PLUGIN_A)); + let b = graph.add_node(fixture.sorting_data(PLUGIN_B)); + let c = graph.add_node(fixture.sorting_data(PLUGIN_C)); + let d = graph.add_node(fixture.sorting_data(PLUGIN_D)); + graph.add_node(fixture.sorting_data(PLUGIN_E)); + graph.add_node(fixture.sorting_data(PLUGIN_F)); + graph.add_node(fixture.sorting_data(PLUGIN_G)); + graph.add_node(fixture.sorting_data(PLUGIN_H)); + graph.add_node(fixture.sorting_data(PLUGIN_I)); + graph.add_node(fixture.sorting_data(PLUGIN_J)); + + // Add a path b -> c -> d -> a + graph.add_edge(b, c, EdgeType::RecordOverlap); + graph.add_edge(c, d, EdgeType::RecordOverlap); + graph.add_edge(d, a, EdgeType::RecordOverlap); + + graph.add_tie_break_edges().unwrap(); + + let sorted = graph.topological_sort().unwrap(); + + assert!(graph.check_path_is_hamiltonian(&sorted).is_none()); + + let sorted_plugin_names: Vec<_> = sorted + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + assert_eq!( + &[ + PLUGIN_B, PLUGIN_C, PLUGIN_D, PLUGIN_A, PLUGIN_E, PLUGIN_F, PLUGIN_G, + PLUGIN_H, PLUGIN_I, PLUGIN_J + ], + sorted_plugin_names.as_slice() + ); + } + } + } + + mod sort_plugins { + use crate::{Vertex, sorting::error::PluginGraphValidationError}; + + use super::*; + + #[test] + fn should_not_change_the_result_if_given_its_own_output() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins( + vec![ + fixture.sorting_data(PLUGIN_B), + fixture.sorting_data(PLUGIN_A), + ], + &fixture.groups_graph, + &[], + ) + .unwrap(); + + assert_eq!(expected, sorted.as_slice()); + + let sorted = sort_plugins( + vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ], + &fixture.groups_graph, + &[], + ) + .unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_group_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_A]); + + let data = vec![ + fixture.group_sorting_data(PLUGIN_A, "A"), + fixture.group_sorting_data(PLUGIN_B, "B"), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_load_after_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_requirement_metadata_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_use_early_loader_positions_when_deciding_relative_plugin_positions() { + let fixture = Fixture::with_plugins(&[PLUGIN_B, PLUGIN_A]); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_A.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_plugin_has_a_group_that_does_not_exist() { + let fixture = Fixture::with_plugins(&[PLUGIN_A]); + + let data = vec![fixture.group_sorting_data(PLUGIN_A, "missing")]; + + assert!(sort_plugins(data, &fixture.groups_graph, &[]).is_err()); + } + + #[test] + fn should_error_if_a_cyclic_interaction_is_encountered() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).add_master(PLUGIN_B); + fixture.get_plugin_mut(PLUGIN_B).add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::CycleFound(e)) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_master_edge_would_contradict_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.add_master(PLUGIN_B); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Master), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_load_after_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_an_early_loader_contradicts_master_flags() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + match sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()).with_out_edge_type(EdgeType::Hardcoded), + Vertex::new(PLUGIN_A.into()).with_out_edge_type(EdgeType::MasterFlag), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_not_error_if_a_master_edge_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.is_blueprint_plugin = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_not_error_if_a_master_edge_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let a = fixture.get_plugin_mut(PLUGIN_A); + a.is_master = true; + a.is_blueprint_plugin = true; + + fixture.get_plugin_mut(PLUGIN_B).add_master(PLUGIN_A); + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_B, PLUGIN_A]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_error_if_a_masterlist_load_after_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_load_after_would_put_a_blueprint_master_before_a_non_master() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_load_after_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_load_after = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserLoadAfter), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_masterlist_requirement_would_put_a_blueprint_master_before_a_non_master() + { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.masterlist_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::MasterlistRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_error_if_a_user_requirement_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let mut a = fixture.sorting_data(PLUGIN_A); + a.user_req = vec![PLUGIN_B.into()]; + + let data = vec![a, fixture.sorting_data(PLUGIN_B)]; + + match sort_plugins(data, &fixture.groups_graph, &[]) { + Err(SortingError::ValidationError(PluginGraphValidationError::CycleFound(e))) => { + assert_eq!( + &[ + Vertex::new(PLUGIN_B.into()) + .with_out_edge_type(EdgeType::UserRequirement), + Vertex::new(PLUGIN_A.into()) + .with_out_edge_type(EdgeType::BlueprintMaster), + ], + e.into_cycle().as_slice() + ); + } + _ => panic!("Expected to find a cycle"), + } + } + + #[test] + fn should_not_error_if_an_early_loader_would_put_a_blueprint_master_before_a_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + fixture.get_plugin_mut(PLUGIN_A).is_master = true; + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + + #[test] + fn should_not_error_if_an_early_loader_would_put_a_blueprint_master_before_a_non_master() { + let mut fixture = Fixture::with_plugins(&[PLUGIN_A, PLUGIN_B]); + + let b = fixture.get_plugin_mut(PLUGIN_B); + b.is_master = true; + b.is_blueprint_plugin = true; + + let data = vec![ + fixture.sorting_data(PLUGIN_A), + fixture.sorting_data(PLUGIN_B), + ]; + + let expected = &[PLUGIN_A, PLUGIN_B]; + + let sorted = sort_plugins(data, &fixture.groups_graph, &[PLUGIN_B.into()]).unwrap(); + + assert_eq!(expected, sorted.as_slice()); + } + } +} diff --git a/src/tests.rs b/src/tests.rs index c4ae348d..d4fd2631 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -5,25 +5,28 @@ use std::{ }; use crate::GameType; +use rstest_reuse::template; use tempfile::TempDir; -const BLANK_ESM: &str = "Blank.esm"; -const BLANK_DIFFERENT_ESM: &str = "Blank - Different.esm"; -const BLANK_MASTER_DEPENDENT_ESM: &str = "Blank - Master Dependent.esm"; +pub const BLANK_ESM: &str = "Blank.esm"; +pub const BLANK_DIFFERENT_ESM: &str = "Blank - Different.esm"; +pub const BLANK_MASTER_DEPENDENT_ESM: &str = "Blank - Master Dependent.esm"; const BLANK_DIFFERENT_MASTER_DEPENDENT_ESM: &str = "Blank - Different Master Dependent.esm"; -const BLANK_ESP: &str = "Blank.esp"; -const BLANK_DIFFERENT_ESP: &str = "Blank - Different.esp"; -const BLANK_MASTER_DEPENDENT_ESP: &str = "Blank - Master Dependent.esp"; +pub const BLANK_ESP: &str = "Blank.esp"; +pub const BLANK_DIFFERENT_ESP: &str = "Blank - Different.esp"; +pub const BLANK_MASTER_DEPENDENT_ESP: &str = "Blank - Master Dependent.esp"; const BLANK_DIFFERENT_MASTER_DEPENDENT_ESP: &str = "Blank - Different Master Dependent.esp"; const BLANK_PLUGIN_DEPENDENT_ESP: &str = "Blank - Plugin Dependent.esp"; const BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP: &str = "Blank - Different Plugin Dependent.esp"; -const BLANK_FULL_ESM: &str = "Blank.full.esm"; -const BLANK_MEDIUM_ESM: &str = "Blank.medium.esm"; -const BLANK_ESL: &str = "Blank.esl"; -const NON_PLUGIN_FILE: &str = "NotAPlugin.esm"; +pub const BLANK_FULL_ESM: &str = "Blank.full.esm"; +pub const BLANK_MEDIUM_ESM: &str = "Blank.medium.esm"; +pub const BLANK_OVERRIDE_ESP: &str = "Blank - Override.esp"; +pub const BLANK_ESL: &str = "Blank.esl"; +pub const NON_PLUGIN_FILE: &str = "NotAPlugin.esm"; +pub const NON_ASCII_ESM: &str = "non\u{00C1}scii.esm"; -fn source_plugins_path(game_type: GameType) -> PathBuf { +pub fn source_plugins_path(game_type: GameType) -> PathBuf { match game_type { GameType::TES3 | GameType::OpenMW => absolute("./testing-plugins/Morrowind/Data Files"), GameType::TES4 => absolute("./testing-plugins/Oblivion/Data"), @@ -48,7 +51,7 @@ fn master_file(game_type: GameType) -> &'static str { } } -fn copy_file(source_dir: &Path, dest_dir: &Path, filename: &str) { +pub fn copy_file(source_dir: &Path, dest_dir: &Path, filename: &str) { copy(source_dir.join(filename), dest_dir.join(filename)).unwrap(); } @@ -70,7 +73,7 @@ fn is_load_order_timestamp_based(game_type: GameType) -> bool { ) } -fn initial_load_order(game_type: GameType) -> Vec<(&'static str, bool)> { +pub fn initial_load_order(game_type: GameType) -> Vec<(&'static str, bool)> { if game_type == GameType::Starfield { vec![ (master_file(game_type), true), @@ -155,6 +158,14 @@ fn set_load_order( } } +fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { + match game_type { + GameType::OpenMW => game_path.join("resources/vfs"), + GameType::TES3 => game_path.join("Data Files"), + _ => game_path.join("Data"), + } +} + pub struct Fixture { _temp_dir: TempDir, pub game_type: GameType, @@ -171,11 +182,7 @@ impl Fixture { let root_path = temp_dir.path(); let game_path = root_path.join("games/game"); let local_path = root_path.join("local/game"); - let data_path = match game_type { - GameType::OpenMW => game_path.join("resources/vfs"), - GameType::TES3 => game_path.join("Data Files"), - _ => game_path.join("Data"), - }; + let data_path = data_path(game_type, &game_path); create_dir_all(&data_path).unwrap(); create_dir_all(&local_path).unwrap(); @@ -208,7 +215,7 @@ impl Fixture { ) .unwrap(); copy( - source_plugins_path.join("Blank - Override.esp"), + source_plugins_path.join(BLANK_OVERRIDE_ESP), data_path.join(BLANK_MASTER_DEPENDENT_ESP), ) .unwrap(); @@ -282,4 +289,68 @@ impl Fixture { local_path, } } + + pub fn data_path(&self) -> PathBuf { + data_path(self.game_type, &self.game_path) + } +} + +#[template] +#[rstest::rstest] +pub fn all_game_types( + #[values( + GameType::TES4, + GameType::TES5, + GameType::FO3, + GameType::FONV, + GameType::FO4, + GameType::TES5SE, + GameType::FO4VR, + GameType::TES5VR, + GameType::TES3, + GameType::Starfield, + GameType::OpenMW + )] + game_type: GameType, +) { +} + +mod unicase { + #[test] + fn eq_should_be_case_insensitive() { + assert!(unicase::eq("i", "I")); + assert!(!unicase::eq("i", "\u{0130}")); + assert!(!unicase::eq("i", "\u{0131}")); + assert!(!unicase::eq("i", "\u{0307}")); + assert!(!unicase::eq("i", "\u{03a1}")); + assert!(!unicase::eq("i", "\u{03c1}")); + assert!(!unicase::eq("i", "\u{03f1}")); + + assert!(!unicase::eq("I", "\u{0130}")); + assert!(!unicase::eq("I", "\u{0131}")); + assert!(!unicase::eq("I", "\u{0307}")); + assert!(!unicase::eq("I", "\u{03a1}")); + assert!(!unicase::eq("I", "\u{03c1}")); + assert!(!unicase::eq("I", "\u{03f1}")); + + assert!(!unicase::eq("\u{0130}", "\u{0131}")); + assert!(!unicase::eq("\u{0130}", "\u{0307}")); + assert!(!unicase::eq("\u{0130}", "\u{03a1}")); + assert!(!unicase::eq("\u{0130}", "\u{03c1}")); + assert!(!unicase::eq("\u{0130}", "\u{03f1}")); + + assert!(!unicase::eq("\u{0131}", "\u{0307}")); + assert!(!unicase::eq("\u{0131}", "\u{03a1}")); + assert!(!unicase::eq("\u{0131}", "\u{03c1}")); + assert!(!unicase::eq("\u{0131}", "\u{03f1}")); + + assert!(!unicase::eq("\u{0307}", "\u{03a1}")); + assert!(!unicase::eq("\u{0307}", "\u{03c1}")); + assert!(!unicase::eq("\u{0307}", "\u{03f1}")); + + assert!(unicase::eq("\u{03a1}", "\u{03c1}")); + assert!(unicase::eq("\u{03a1}", "\u{03f1}")); + + assert!(unicase::eq("\u{03c1}", "\u{03f1}")); + } }