diff --git a/Cargo.lock b/Cargo.lock index 02bc224e..66896cb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,15 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "array-parameterized-test" +version = "0.27.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -545,12 +554,12 @@ dependencies = [ name = "libloot" version = "0.27.0" dependencies = [ + "array-parameterized-test", "crc32fast", "esplugin", "libloadorder", "log", "loot-condition-interpreter", - "parameterized-test", "petgraph", "rayon", "regress", @@ -774,15 +783,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "parameterized-test" -version = "0.27.0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pelite" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 1c5955d7..85b85521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ unicase = "2.8.1" windows = { version = "0.61.1", features = ["Win32_Storage_FileSystem"] } [dev-dependencies] -parameterized-test = { path = "./parameterized-test" } +array-parameterized-test = { path = "./array-parameterized-test" } tempfile = "3.17.1" [lints] @@ -35,7 +35,7 @@ debug = "limited" lto = "thin" [workspace] -members = ["cpp", "ffi-errors", "nodejs", "parameterized-test", "python"] +members = ["cpp", "ffi-errors", "nodejs", "array-parameterized-test", "python"] [workspace.package] version = "0.27.0" @@ -103,7 +103,7 @@ multiple_unsafe_ops_per_block = "deny" mutex_atomic = "forbid" mutex_integer = "forbid" needless_raw_strings = "forbid" -non_ascii_literal = "forbid" +non_ascii_literal = "deny" non_zero_suggestions = "forbid" panic = "deny" panic_in_result_fn = "forbid" diff --git a/parameterized-test/Cargo.toml b/array-parameterized-test/Cargo.toml similarity index 74% rename from parameterized-test/Cargo.toml rename to array-parameterized-test/Cargo.toml index 904c9087..e896bf15 100644 --- a/parameterized-test/Cargo.toml +++ b/array-parameterized-test/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "parameterized-test" -description = "Procedural macros to support parameterized testing in libloot." +name = "array-parameterized-test" +description = "Parameterized testing using an input array." categories = ["development-tools::testing"] version.workspace = true repository.workspace = true diff --git a/array-parameterized-test/README.md b/array-parameterized-test/README.md new file mode 100644 index 00000000..759ba918 --- /dev/null +++ b/array-parameterized-test/README.md @@ -0,0 +1,33 @@ +# array-parameterized-test + +A few macros to support parameterized test cases where the inputs are defined using an const array. This can be used to concisely define several different parameterized tests that share the same set of inputs. + +Use it like this: + +```rust +use array_parameterized_test::{test_parameter, parameterized_test}; + +#[test_parameter] +const INT_INPUTS: [u32; 2] = [1, 2]; + +#[parameterized_test(INT_INPUTS)] +fn test_with_ints(value: u32) { + assert!(value > 0 && value < 3); +} +``` + +which produces tests that look like this: + +``` +test test_with_ints::_1 ... ok +test test_with_ints::_2 ... ok +``` + +See the tests for more examples. + +The macros that implement this functionality may not work with arbitrary array element types. They've been tested with: + +- u32 +- &str +- (u32, bool) +- Unit-only enums diff --git a/array-parameterized-test/src/lib.rs b/array-parameterized-test/src/lib.rs new file mode 100644 index 00000000..7bb1fd52 --- /dev/null +++ b/array-parameterized-test/src/lib.rs @@ -0,0 +1,144 @@ +use proc_macro2::{Group, TokenStream}; +use quote::{ToTokens, format_ident, quote}; +use syn::{Expr, Ident, ItemConst, ItemFn, Token, parse, parse_macro_input}; + +/// Use as an attribute to annotate a parameterized test function that accepts a single parameter. The attribute must be given the name of a const array that is annotated with the `test_parameter` attribute. That const array will be used to supply parameter values to the test function. +#[proc_macro_attribute] +pub fn parameterized_test( + input: proc_macro::TokenStream, + annotated_item: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + let macro_name = parse_macro_input!(input as Ident); + let test = parse_macro_input!(annotated_item as ItemFn); + + let inner_func_name = test.sig.ident.clone(); + + quote! { + mod #inner_func_name { + use super::*; + + #test + + #macro_name!{#inner_func_name} + } + } + .into() +} + +/// Use as an attribute to annotate a const array that will be used to supply the input values for a parameterized test. +/// +/// # Panics +/// +/// Panics if the annotated item is not a const array expression. Will also panic if the array contains a path expression that is somehow empty. +#[expect(clippy::expect_used, clippy::panic)] +#[proc_macro_attribute] +pub fn test_parameter( + _input: proc_macro::TokenStream, + annotated_item: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + let cloned_item_tokens = annotated_item.clone(); + let item = parse_macro_input!(cloned_item_tokens as ItemConst); + + let Expr::Array(array) = item.expr.as_ref() else { + panic!("Expected expression to be an array"); + }; + + let values: Vec<_> = array + .elems + .iter() + .map(|n| { + if let Expr::Path(path) = n { + path.path + .segments + .last() + .expect("path expressions in the const array to have at least one segment") + .ident + .to_token_stream() + } else { + n.to_token_stream() + } + }) + .collect(); + + let annotated_item = TokenStream::from(annotated_item); + + let const_item_name = item.ident; + let macro_name = format_ident!("{}_macro", &const_item_name); + + let macro_output = quote! { + macro_rules! #macro_name { + ( $inner_test_name:ident ) => { + array_parameterized_test::generate_tests!{ + $inner_test_name, + #const_item_name, + [#(#values),*] + } + }; + } + + #[allow(unused_imports)] + pub(crate) use #macro_name as #const_item_name; + + #annotated_item + }; + + macro_output.into() +} + +struct GenerateTestsInput { + inner_test_name: Ident, + const_item_name: Ident, + const_item_values: Group, +} + +impl syn::parse::Parse for GenerateTestsInput { + fn parse(input: parse::ParseStream) -> syn::Result { + let inner_test_name = input.parse()?; + let _: Token![,] = input.parse()?; + let const_item_name = input.parse()?; + let _: Token![,] = input.parse()?; + let const_item_values = input.parse()?; + + Ok(GenerateTestsInput { + inner_test_name, + const_item_name, + const_item_values, + }) + } +} + +/// A macro used to generate multiple test functions given a parameterized test function name, a const array identifier and the array's values. +#[proc_macro] +pub fn generate_tests(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let GenerateTestsInput { + inner_test_name, + const_item_name, + const_item_values, + } = parse_macro_input!(input as GenerateTestsInput); + + let tokens: proc_macro2::TokenStream = const_item_values + .stream() + .into_iter() + .step_by(2) + .enumerate() + .flat_map(|(i, value)| { + let suffix = value + .to_string() + .escape_default() + .collect::() + .replace(|c: char| !c.is_ascii_alphanumeric(), "_"); + + let test_name = format_ident!("_{suffix}"); + + quote! { + #[test] + #[allow(non_snake_case)] + fn #test_name() { + #inner_test_name(#const_item_name[#i]); + } + } + }) + .collect(); + + tokens.into() +} diff --git a/array-parameterized-test/tests/integration_test.rs b/array-parameterized-test/tests/integration_test.rs new file mode 100644 index 00000000..cda0c1a8 --- /dev/null +++ b/array-parameterized-test/tests/integration_test.rs @@ -0,0 +1,51 @@ +use array_parameterized_test::{parameterized_test, test_parameter}; + +#[derive(Copy, Clone, Eq, PartialEq)] +enum TestValue { + A, + B, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test_parameter] + const INT_INPUTS: [u32; 2] = [1, 2]; + + #[test_parameter] + const ENUM_INPUTS: [TestValue; 2] = [TestValue::A, TestValue::B]; + + #[test_parameter] + const TUPLE_INPUTS: [(u32, bool); 5] = + [(1, false), (2, true), (3, false), (4, true), (5, false)]; + + #[expect(clippy::non_ascii_literal)] + #[test_parameter] + const NON_ASCII_INPUTS: [&str; 2] = ["nonÁscii", "藏"]; + + #[parameterized_test(INT_INPUTS)] + fn test_with_ints(value: u32) { + assert!(value > 0 && value < 3); + } + + #[parameterized_test(ENUM_INPUTS)] + fn test_with_enums(value: TestValue) { + assert!(value == TestValue::A || value == TestValue::B); + } + + #[parameterized_test(NON_ASCII_INPUTS)] + fn test_with_non_ascii_strings(value: &str) { + assert!(!value.is_ascii()); + } + + #[parameterized_test(TUPLE_INPUTS)] + fn test_with_tuples(value: (u32, bool)) { + assert_eq!(value.1, value.0.is_multiple_of(2)); + } + + #[parameterized_test(TUPLE_INPUTS)] + fn test_with_tuples_destructured((value, expected): (u32, bool)) { + assert_eq!(expected, value.is_multiple_of(2)); + } +} diff --git a/array-parameterized-test/tests/realistic.rs b/array-parameterized-test/tests/realistic.rs new file mode 100644 index 00000000..91d741ac --- /dev/null +++ b/array-parameterized-test/tests/realistic.rs @@ -0,0 +1,85 @@ +use std::path::Path; + +use array_parameterized_test::{parameterized_test, test_parameter}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum GameType { + Oblivion, + Skyrim, + Fallout3, + FalloutNV, + Fallout4, + SkyrimSE, + Fallout4VR, + SkyrimVR, + Morrowind, + Starfield, + OpenMW, + OblivionRemastered, +} + +fn has_ascii_extension(path: &Path, extension: &str) -> bool { + path.extension() + .is_some_and(|e| e.eq_ignore_ascii_case(extension)) +} + +fn has_plugin_file_extension(game_type: GameType, plugin_path: &Path) -> bool { + let extension = if game_type != GameType::OpenMW && has_ascii_extension(plugin_path, "ghost") { + plugin_path + .file_stem() + .and_then(|s| Path::new(s).extension()) + } else { + plugin_path.extension() + }; + + if let Some(extension) = extension { + if extension.eq_ignore_ascii_case("esp") + || extension.eq_ignore_ascii_case("esm") + || (game_type == GameType::OpenMW + && (extension.eq_ignore_ascii_case("omwaddon") + || extension.eq_ignore_ascii_case("omwgame") + || extension.eq_ignore_ascii_case("omwscripts"))) + { + true + } else { + matches!( + game_type, + GameType::Fallout4 + | GameType::Fallout4VR + | GameType::SkyrimSE + | GameType::SkyrimVR + | GameType::Starfield + ) && extension.eq_ignore_ascii_case("esl") + } + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test_parameter] + const ALL_GAME_TYPES: [GameType; 12] = [ + GameType::Oblivion, + GameType::Skyrim, + GameType::Fallout3, + GameType::FalloutNV, + GameType::Fallout4, + GameType::SkyrimSE, + GameType::Fallout4VR, + GameType::SkyrimVR, + GameType::Morrowind, + GameType::Starfield, + GameType::OpenMW, + GameType::OblivionRemastered, + ]; + + #[parameterized_test(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"))); + } +} diff --git a/docs/scripts/licenses.py b/docs/scripts/licenses.py index e37159dd..24a1067b 100644 --- a/docs/scripts/licenses.py +++ b/docs/scripts/licenses.py @@ -114,7 +114,7 @@ if __name__ == "__main__": 'libloot-cpp', 'libloot-nodejs', 'libloot-python', - 'parameterized-test', + 'array-parameterized-test', 'esplugin', 'libloadorder' ] diff --git a/parameterized-test/src/lib.rs b/parameterized-test/src/lib.rs deleted file mode 100644 index cd0e51fe..00000000 --- a/parameterized-test/src/lib.rs +++ /dev/null @@ -1,146 +0,0 @@ -#![allow( - clippy::missing_panics_doc, - clippy::unwrap_used, - clippy::panic, - clippy::wildcard_enum_match_arm -)] -use proc_macro::{TokenStream, TokenTree}; -use quote::{ToTokens, format_ident, quote}; -use syn::{Expr, ExprLit, FnArg, Ident, ItemConst, ItemFn, Lit, Pat, PatIdent, PatType, parse}; - -#[proc_macro_attribute] -pub fn parameterized_test(input: TokenStream, annotated_item: TokenStream) -> TokenStream { - let macro_name: Ident = parse(input).unwrap(); - - let test: ItemFn = parse(annotated_item.clone()).unwrap(); - - let Some(FnArg::Typed(PatType { - pat: type_pattern, .. - })) = test.sig.inputs.first() - else { - panic!("Expected the first test function argument a type pattern"); - }; - - let Pat::Ident(PatIdent { - ident: inner_func_arg_name, - .. - }) = type_pattern.as_ref() - else { - panic!("Expected the first test function argument pattern to be an ident"); - }; - - let inner_func_name = test.sig.ident.clone(); - - quote! { - mod #inner_func_name { - use super::*; - - #test - - #macro_name!{#inner_func_name, #inner_func_arg_name} - } - } - .into() -} - -#[proc_macro_attribute] -pub fn test_parameter(_input: TokenStream, annotated_item: TokenStream) -> TokenStream { - let item: ItemConst = parse(annotated_item.clone()).unwrap(); - - let Expr::Array(array) = item.expr.as_ref() else { - panic!("Expected expression to be an array"); - }; - - let values: Vec<_> = array - .elems - .iter() - .map(|n| match n { - Expr::Path(path) => path.path.segments.last().unwrap().ident.to_token_stream(), - Expr::Lit(ExprLit { - lit: Lit::Int(lit_int), - .. - }) => lit_int.to_token_stream(), - _ => panic!("Expected array element to be a path or int literal"), - }) - .collect(); - - let annotated_item = proc_macro2::TokenStream::from(annotated_item); - - let const_item_name = item.ident; - let macro_name = format_ident!("{}_macro", &const_item_name); - - let macro_output = quote! { - macro_rules! #macro_name { - ( $inner_test_name:ident, $inner_test_arg_name:ident ) => { - parameterized_test::generate_tests!{ - $inner_test_name, - $inner_test_arg_name, - #const_item_name, - [#(#values),*] - } - }; - } - - #[allow(unused_imports)] - pub(crate) use #macro_name as #const_item_name; - - #annotated_item - }; - - macro_output.into() -} - -#[proc_macro] -pub fn generate_tests(item: TokenStream) -> TokenStream { - let mut item_iter = item.into_iter(); - let TokenTree::Ident(inner_test_name) = item_iter.next().unwrap() else { - panic!("Expected an ident for the inner_test_name"); - }; - - drop(item_iter.next()); - - let TokenTree::Ident(inner_test_param_name) = item_iter.next().unwrap() else { - panic!("Expected an ident for the inner_test_param_name"); - }; - - drop(item_iter.next()); - - let TokenTree::Ident(const_item_name) = item_iter.next().unwrap() else { - panic!("Expected an ident for the const_item_name"); - }; - - drop(item_iter.next()); - - let TokenTree::Group(const_item_values) = item_iter.next().unwrap() else { - panic!("Expected a group for the const_item_values"); - }; - - let inner_test_name: Ident = parse(TokenTree::from(inner_test_name).into()).unwrap(); - let const_item_name: Ident = parse(TokenTree::from(const_item_name).into()).unwrap(); - - let tokens: proc_macro2::TokenStream = const_item_values - .stream() - .into_iter() - .step_by(2) - .enumerate() - .flat_map(|(i, value)| { - let suffix = match value { - TokenTree::Ident(ident) => ident.to_string(), - TokenTree::Literal(literal) => literal.to_string(), - _ => panic!("Expected const item value to be an ident or literal"), - }; - - let test_name = format_ident!("{inner_test_param_name}_{i:02}_{suffix}"); - - quote! { - #[test] - #[allow(non_snake_case)] - fn #test_name() { - #inner_test_name(#const_item_name[#i]); - } - } - }) - .collect(); - - tokens.into() -} diff --git a/src/archive/find.rs b/src/archive/find.rs index 38bc08ff..ad48ee4d 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -186,7 +186,7 @@ mod tests { mod find_associated_archives { use std::path::absolute; - use parameterized_test::parameterized_test; + use array_parameterized_test::parameterized_test; use tempfile::TempDir; use super::*; diff --git a/src/archive/parse.rs b/src/archive/parse.rs index e8f9e079..007fe4f7 100644 --- a/src/archive/parse.rs +++ b/src/archive/parse.rs @@ -104,7 +104,7 @@ mod tests { io::SeekFrom, }; - use parameterized_test::{parameterized_test, test_parameter}; + use array_parameterized_test::{parameterized_test, test_parameter}; use tempfile::tempdir; use super::*; diff --git a/src/game.rs b/src/game.rs index ab1097da..596bae07 100644 --- a/src/game.rs +++ b/src/game.rs @@ -810,7 +810,7 @@ impl GameCache { mod tests { use super::*; - use parameterized_test::parameterized_test; + use array_parameterized_test::parameterized_test; use crate::{ metadata::{File, PluginMetadata}, diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 84ac2576..d5ad8e5a 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -463,7 +463,7 @@ mod tests { use super::*; use crate::tests::ALL_GAME_TYPES; - use parameterized_test::parameterized_test; + use array_parameterized_test::parameterized_test; mod plugin { use std::io::Seek; diff --git a/src/tests.rs b/src/tests.rs index 8ddd8aa0..abf607a4 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -5,7 +5,7 @@ use std::{ }; use crate::GameType; -use parameterized_test::test_parameter; +use array_parameterized_test::test_parameter; use tempfile::TempDir; pub(crate) const BLANK_ESM: &str = "Blank.esm";