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.
This commit is contained in:
Oliver Hamlet
2025-03-25 20:56:19 +00:00
parent 597ae75c3c
commit 7f62d494e9
21 changed files with 8363 additions and 165 deletions
+2 -2
View File
@@ -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
+289 -6
View File
@@ -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));
}
}
}
+61
View File
@@ -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<u64, BTreeSet<u64>>,
other_assets: &BTreeMap<u64, BTreeSet<u64>>,
) -> 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))
}
}
}
+192
View File
@@ -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<T: 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());
}
}
}
+75
View File
@@ -106,3 +106,78 @@ fn filter_cleaning_data_on_conditions(
})
.collect::<Result<Vec<_>, _>>()
}
#[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());
}
}
}
+916 -1
View File
File diff suppressed because it is too large Load Diff
+1301 -36
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -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;
+42
View File
@@ -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;
+49 -2
View File
@@ -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!(
+292 -2
View File
@@ -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::*;
File diff suppressed because it is too large Load Diff
+8
View File
@@ -26,3 +26,11 @@ fn emit<T: yaml_emit::EmitYaml>(metadata: &T) -> String {
emitter.into_string()
}
#[cfg(test)]
fn parse(yaml: &str) -> saphyr::MarkedYaml {
saphyr::MarkedYaml::load_from_str(yaml)
.unwrap()
.pop()
.unwrap()
}
+102
View File
@@ -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;
+485
View File
@@ -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};
+71
View File
@@ -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;
+894 -24
View File
File diff suppressed because it is too large Load Diff
+210
View File
@@ -267,3 +267,213 @@ pub fn get_default_group_node(graph: &GroupsGraph) -> Result<NodeIndex, Undefine
.find(|n| graph[*n] == Group::DEFAULT_NAME)
.ok_or_else(|| UndefinedGroupError::new(Group::DEFAULT_NAME.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
mod build_groups_graph {
use super::*;
#[test]
fn should_error_if_an_after_group_does_not_exist() {
let groups = &[Group::new("b".into()).with_after_groups(vec!["a".into()])];
match build_groups_graph(groups, &[]) {
Err(BuildGroupsGraphError::UndefinedGroup(e)) => {
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()
);
}
}
}
}
+75
View File
@@ -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<String>,
pub is_master: bool,
pub is_blueprint_plugin: bool,
pub override_record_count: usize,
pub asset_count: usize,
overlapping_record_plugins: Vec<String>,
overlapping_asset_plugins: Vec<String>,
}
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<Vec<String>, PluginDataError> {
Ok(self.masters.clone())
}
fn override_record_count(&self) -> Result<usize, PluginDataError> {
Ok(self.override_record_count)
}
fn asset_count(&self) -> usize {
self.asset_count
}
fn do_records_overlap(&self, other: &Self) -> Result<bool, PluginDataError> {
Ok(self.overlapping_record_plugins.contains(&other.name))
}
fn do_assets_overlap(&self, other: &Self) -> bool {
self.overlapping_asset_plugins.contains(&other.name)
}
}
}
+2566 -2
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More