diff --git a/.gitignore b/.gitignore index ea8c4bf7..1ca09fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/testing-plugins diff --git a/Cargo.lock b/Cargo.lock index a2812b6d..3ca6a175 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + [[package]] name = "bit-set" version = "0.8.0" @@ -65,6 +71,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cfg-if" version = "1.0.0" @@ -271,6 +283,49 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -304,6 +359,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "hashbrown" version = "0.14.5" @@ -384,6 +445,8 @@ dependencies = [ "loot-condition-interpreter", "petgraph", "rayon", + "rstest", + "rstest_reuse", "saphyr", "saphyr-parser", "tempfile", @@ -558,6 +621,36 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.93" @@ -576,6 +669,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + [[package]] name = "rayon" version = "1.10.0" @@ -636,6 +759,53 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rstest_reuse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" +dependencies = [ + "quote", + "rand", + "syn", +] + [[package]] name = "rust-ini" version = "0.21.1" @@ -648,6 +818,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -683,6 +862,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + [[package]] name = "sha2" version = "0.10.8" @@ -694,6 +879,15 @@ dependencies = [ "digest", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "syn" version = "2.0.98" @@ -768,6 +962,23 @@ dependencies = [ "crunchy", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "trim-in-place" version = "0.1.7" @@ -1015,6 +1226,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" @@ -1030,6 +1250,7 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ + "byteorder", "zerocopy-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 4b43d1bf..a47617d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,6 @@ unicase = "2.8.1" rayon = "1.10.0" [dev-dependencies] +rstest = "0.25.0" +rstest_reuse = "0.7.0" tempfile = "3.17.1" diff --git a/README.md b/README.md index 5dea8e08..21d13c18 100644 --- a/README.md +++ b/README.md @@ -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 using: +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: ``` cargo test diff --git a/src/game.rs b/src/game.rs index 9fa2f1ea..b0670879 100644 --- a/src/game.rs +++ b/src/game.rs @@ -753,3 +753,60 @@ impl GameCache { self.archive_paths.iter() } } + +#[cfg(test)] +mod tests { + use super::*; + + use rstest::rstest; + use rstest_reuse::{apply, template}; + + use crate::tests::Fixture; + + #[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 new { + use super::*; + + #[apply(all_game_types)] + fn should_succeed_if_given_valid_game_path(game_type: GameType) { + let fixture = Fixture::new(game_type); + + let game = Game::new(fixture.game_type, &fixture.game_path); + + assert!(game.is_ok()); + } + } + + 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()); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4abe3ac8..1c3e31da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ mod logging; pub mod metadata; mod plugin; mod sorting; +#[cfg(test)] +mod tests; mod version; pub use database::Database; diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 00000000..c4ae348d --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,285 @@ +use std::{ + fs::{File, copy, create_dir_all}, + path::{Path, PathBuf, absolute}, + time::{Duration, SystemTime}, +}; + +use crate::GameType; +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"; +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"; +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"; + +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"), + GameType::Starfield => absolute("./testing-plugins/Starfield/Data"), + GameType::FO3 | GameType::FONV | GameType::TES5 => { + absolute("./testing-plugins/Skyrim/Data") + } + _ => absolute("./testing-plugins/SkyrimSE/Data"), + } + .unwrap() +} + +fn master_file(game_type: GameType) -> &'static str { + match game_type { + GameType::TES3 | GameType::OpenMW => "Morrowind.esm", + GameType::TES4 => "Oblivion.esm", + GameType::TES5 | GameType::TES5SE | GameType::TES5VR => "Skyrim.esm", + GameType::FO3 => "Fallout3.esm", + GameType::FONV => "FalloutNV.esm", + GameType::FO4 | GameType::FO4VR => "Fallout4.esm", + GameType::Starfield => "Starfield.esm", + } +} + +fn copy_file(source_dir: &Path, dest_dir: &Path, filename: &str) { + copy(source_dir.join(filename), dest_dir.join(filename)).unwrap(); +} + +fn touch(file_path: &Path) { + std::fs::File::create(file_path).unwrap(); +} + +fn supports_light_plugins(game_type: GameType) -> bool { + matches!( + game_type, + GameType::TES5SE | GameType::TES5VR | GameType::FO4 | GameType::FO4VR | GameType::Starfield + ) +} + +fn is_load_order_timestamp_based(game_type: GameType) -> bool { + matches!( + game_type, + GameType::TES3 | GameType::TES4 | GameType::FO3 | GameType::FONV + ) +} + +fn initial_load_order(game_type: GameType) -> Vec<(&'static str, bool)> { + if game_type == GameType::Starfield { + vec![ + (master_file(game_type), true), + (BLANK_ESM, true), + (BLANK_DIFFERENT_ESM, false), + (BLANK_FULL_ESM, false), + (BLANK_MASTER_DEPENDENT_ESM, false), + (BLANK_MEDIUM_ESM, false), + (BLANK_ESL, false), + (BLANK_ESP, false), + (BLANK_DIFFERENT_ESP, false), + (BLANK_MASTER_DEPENDENT_ESP, false), + ] + } else { + let mut load_order = vec![ + (master_file(game_type), true), + (BLANK_ESM, true), + (BLANK_DIFFERENT_ESM, false), + (BLANK_MASTER_DEPENDENT_ESM, false), + (BLANK_DIFFERENT_MASTER_DEPENDENT_ESM, false), + (BLANK_ESP, false), + (BLANK_DIFFERENT_ESP, false), + (BLANK_MASTER_DEPENDENT_ESP, false), + (BLANK_DIFFERENT_MASTER_DEPENDENT_ESP, true), + (BLANK_PLUGIN_DEPENDENT_ESP, false), + (BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP, false), + ]; + + if supports_light_plugins(game_type) { + load_order.insert(5, (BLANK_ESL, false)); + } + + load_order + } +} + +fn set_load_order( + game_type: GameType, + data_path: &Path, + local_path: &Path, + load_order: &[(&'static str, bool)], +) { + use std::io::Write; + + match game_type { + GameType::TES3 => {} + GameType::OpenMW => {} + _ => { + let mut file = File::create(local_path.join("Plugins.txt")).unwrap(); + for (plugin, is_active) in load_order { + if supports_light_plugins(game_type) { + if *is_active { + write!(file, "*").unwrap(); + } + } else if !is_active { + continue; + } + + writeln!(file, "{plugin}").unwrap(); + } + } + } + + if is_load_order_timestamp_based(game_type) { + let mut mod_time = SystemTime::now(); + for (plugin, _) in load_order { + let ghosted_path = data_path.join(plugin.to_string() + ".ghost"); + let file = if ghosted_path.exists() { + File::options().write(true).open(ghosted_path) + } else { + File::options().write(true).open(data_path.join(plugin)) + }; + file.unwrap().set_modified(mod_time).unwrap(); + + mod_time += Duration::from_secs(60); + } + } else if game_type == GameType::TES5 { + let mut file = File::create(local_path.join("loadorder.txt")).unwrap(); + for (plugin, _) in load_order { + writeln!(file, "{plugin}").unwrap(); + } + } +} + +pub struct Fixture { + _temp_dir: TempDir, + pub game_type: GameType, + pub game_path: PathBuf, + pub local_path: PathBuf, +} + +impl Fixture { + pub fn new(game_type: GameType) -> Self { + let temp_dir = tempfile::Builder::new() + .prefix("libloot-t\u{00E9}st-") + .tempdir() + .unwrap(); + 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"), + }; + + create_dir_all(&data_path).unwrap(); + create_dir_all(&local_path).unwrap(); + + let source_plugins_path = source_plugins_path(game_type); + + if game_type == GameType::Starfield { + copy_file(&source_plugins_path, &data_path, BLANK_FULL_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_MEDIUM_ESM); + + copy( + source_plugins_path.join(BLANK_FULL_ESM), + data_path.join(BLANK_ESM), + ) + .unwrap(); + copy( + source_plugins_path.join(BLANK_FULL_ESM), + data_path.join(BLANK_DIFFERENT_ESM), + ) + .unwrap(); + copy( + source_plugins_path.join("Blank - Override.full.esm"), + data_path.join(BLANK_MASTER_DEPENDENT_ESM), + ) + .unwrap(); + copy_file(&source_plugins_path, &data_path, BLANK_ESP); + copy( + source_plugins_path.join(BLANK_ESP), + data_path.join(BLANK_DIFFERENT_ESP), + ) + .unwrap(); + copy( + source_plugins_path.join("Blank - Override.esp"), + data_path.join(BLANK_MASTER_DEPENDENT_ESP), + ) + .unwrap(); + } else { + copy_file(&source_plugins_path, &data_path, BLANK_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_DIFFERENT_ESM); + copy_file(&source_plugins_path, &data_path, BLANK_MASTER_DEPENDENT_ESM); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_MASTER_DEPENDENT_ESM, + ); + copy_file(&source_plugins_path, &data_path, BLANK_ESP); + copy_file(&source_plugins_path, &data_path, BLANK_DIFFERENT_ESP); + copy_file(&source_plugins_path, &data_path, BLANK_MASTER_DEPENDENT_ESP); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_MASTER_DEPENDENT_ESP, + ); + copy_file(&source_plugins_path, &data_path, BLANK_PLUGIN_DEPENDENT_ESP); + copy_file( + &source_plugins_path, + &data_path, + BLANK_DIFFERENT_PLUGIN_DEPENDENT_ESP, + ); + } + + if supports_light_plugins(game_type) { + if game_type == GameType::Starfield { + copy( + source_plugins_path.join("Blank.small.esm"), + data_path.join(BLANK_ESL), + ) + .unwrap(); + } else { + copy_file(&source_plugins_path, &data_path, BLANK_ESL); + } + } + + let master_file = master_file(game_type); + copy(data_path.join(BLANK_ESM), data_path.join(master_file)).unwrap(); + + set_load_order( + game_type, + &data_path, + &local_path, + &initial_load_order(game_type), + ); + + if game_type == GameType::OpenMW { + touch(&game_path.join("openmw.cfg")); + } else { + std::fs::rename( + data_path.join(BLANK_MASTER_DEPENDENT_ESM), + data_path.join(BLANK_MASTER_DEPENDENT_ESM.to_string() + ".ghost"), + ) + .unwrap(); + } + + std::fs::write( + data_path.join(NON_PLUGIN_FILE), + "This isn't a valid plugin file.", + ) + .unwrap(); + + Self { + _temp_dir: temp_dir, + game_type, + game_path, + local_path, + } + } +}