diff --git a/Cargo.lock b/Cargo.lock index 098ab28e..697c9934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,9 +379,9 @@ dependencies = [ [[package]] name = "esplugin" -version = "6.1.2" +version = "6.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8b3f4507e489fce3df78273537e93e939916c83d992fbfb712db9b1f5eee62" +checksum = "3c334f10e3dc2e21d45e208f5b6b232a6876ea6352130d90a42bdb05246f7fcc" dependencies = [ "encoding_rs", "memchr", @@ -528,9 +528,9 @@ dependencies = [ [[package]] name = "libloadorder" -version = "18.3.0" +version = "18.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034ffaf7bf5bd34669741500d4ccddc5dbc62c491cdb0c5e103c592da535dbeb" +checksum = "bc4de694d488b4ce2a775d59825ec30d206546ec87fe0866717e6db25c46a164" dependencies = [ "dirs", "encoding_rs", @@ -545,7 +545,7 @@ dependencies = [ [[package]] name = "libloot" -version = "0.26.0" +version = "0.26.1" dependencies = [ "crc32fast", "esplugin", @@ -566,7 +566,7 @@ dependencies = [ [[package]] name = "libloot-cpp" -version = "0.26.0" +version = "0.26.1" dependencies = [ "cxx", "cxx-build", @@ -578,7 +578,7 @@ dependencies = [ [[package]] name = "libloot-ffi-errors" -version = "0.26.0" +version = "0.26.1" dependencies = [ "esplugin", "libloadorder", @@ -588,7 +588,7 @@ dependencies = [ [[package]] name = "libloot-nodejs" -version = "0.26.0" +version = "0.26.1" dependencies = [ "libloot", "libloot-ffi-errors", @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "libloot_python" -version = "0.26.0" +version = "0.26.1" dependencies = [ "libloot", "libloot-ffi-errors", @@ -640,9 +640,9 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "loot-condition-interpreter" -version = "5.3.1" +version = "5.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d58882c85aba2f4fca0951301b58e03428a8a02e51ccb0cf5f5b28d7869281" +checksum = "eec12dc709a84442632efdc064e3463612bf7ddcc39d804de021cf56e557be04" dependencies = [ "crc32fast", "esplugin", diff --git a/Cargo.toml b/Cargo.toml index d73b7cc4..9023661f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot" -version = "0.26.0" +version = "0.26.1" edition = "2024" license = "GPL-3.0" @@ -30,9 +30,9 @@ tempfile = "3.17.1" members = ["cpp", "ffi-errors", "nodejs", "parameterized-test", "python"] [workspace.dependencies] -esplugin = "6.1.2" -libloadorder = "18.3.0" -loot-condition-interpreter = "5.3.1" +esplugin = "6.1.3" +libloadorder = "18.4.0" +loot-condition-interpreter = "5.3.2" [profile.release] debug = "limited" diff --git a/README.md b/README.md index c79ff68c..e18da3c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # libloot-rs -This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.0. +This is an **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++, that should match libloot v0.26.1. + +There is one intentional difference in behaviour: if a plugin has metadata but it is all filtered out when conditions are evaluated, getting its metadata returns `None` instead of a name-only PluginMetadata object. There are C++, Python and Node.js wrappers in the `cpp`, `python` and `nodejs` subdirectories respectively. diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 5f234e79..14db90ee 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -67,8 +67,10 @@ set(LIBLOOT_SRC_API_CPP_FILES "${CMAKE_SOURCE_DIR}/src/api/api.cpp" "${CMAKE_SOURCE_DIR}/src/api/convert.cpp" "${CMAKE_SOURCE_DIR}/src/api/database.cpp" - "${CMAKE_SOURCE_DIR}/src/api/error_categories.cpp" - "${CMAKE_SOURCE_DIR}/src/api/exception.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/cyclic_interaction_error.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/error_categories.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/exception.cpp" + "${CMAKE_SOURCE_DIR}/src/api/exception/undefined_group_error.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/conditional_metadata.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/file.cpp" "${CMAKE_SOURCE_DIR}/src/api/metadata/filename.cpp" @@ -81,8 +83,6 @@ set(LIBLOOT_SRC_API_CPP_FILES "${CMAKE_SOURCE_DIR}/src/api/metadata/tag.cpp" "${CMAKE_SOURCE_DIR}/src/api/game.cpp" "${CMAKE_SOURCE_DIR}/src/api/plugin.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/cyclic_interaction_error.cpp" - "${CMAKE_SOURCE_DIR}/src/api/sorting/undefined_group_error.cpp" "${CMAKE_SOURCE_DIR}/src/api/vertex.cpp") set(LIBLOOT_INCLUDE_H_FILES @@ -116,7 +116,7 @@ set(LIBLOOT_INCLUDE_H_FILES set(LIBLOOT_SRC_API_H_FILES "${CMAKE_SOURCE_DIR}/src/api/convert.h" "${CMAKE_SOURCE_DIR}/src/api/database.h" - "${CMAKE_SOURCE_DIR}/src/api/exception.h" + "${CMAKE_SOURCE_DIR}/src/api/exception/exception.h" "${CMAKE_SOURCE_DIR}/src/api/game.h" "${CMAKE_SOURCE_DIR}/src/api/plugin.h") @@ -239,7 +239,7 @@ endif() # Install ######################################## -set(LIBLOOT_VERSION "0.26.0") +set(LIBLOOT_VERSION "0.26.1") set_property(TARGET loot PROPERTY VERSION ${LIBLOOT_VERSION}) set_property(TARGET loot PROPERTY SOVERSION 0) diff --git a/cpp/Cargo.toml b/cpp/Cargo.toml index 6c44e949..c22adf5d 100644 --- a/cpp/Cargo.toml +++ b/cpp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot-cpp" -version = "0.26.0" +version = "0.26.1" edition = "2024" license = "GPL-3.0" diff --git a/cpp/README.md b/cpp/README.md index aa4a7dc1..249f6cda 100644 --- a/cpp/README.md +++ b/cpp/README.md @@ -1,6 +1,6 @@ # libloot-rs C++ wrapper -This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.26.0. +This is an **experimental** wrapper around the Rust reimplementation of libloot that provides a C++ interface that's ABI-compatible with libloot v0.26.1. The wrapper has two layers: @@ -44,7 +44,7 @@ cmake --build build --parallel ### Tests & Packaging -The build process also builds a copy of the public API tests from C++ libloot v0.26.0 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. +The build process also builds a copy of the public API tests from C++ libloot v0.26.1 by default. To skip building the tests, pass `-DLIBLOOT_BUILD_TESTS=OFF` when first running CMake. If built, the tests can be run using: diff --git a/cpp/cmake/tests.cmake b/cpp/cmake/tests.cmake index 27c4636a..fbd1b515 100644 --- a/cpp/cmake/tests.cmake +++ b/cpp/cmake/tests.cmake @@ -36,16 +36,7 @@ set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_INITIAL}) set(LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/internals/main.cpp") -set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/conditional_metadata_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/file_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/group_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/location_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/message_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/message_content_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/plugin_cleaning_data_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/plugin_metadata_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata/tag_test.h") +# set(LIBLOOT_SRC_TESTS_INTERNALS_H_FILES) set(LIBLOOT_SRC_TESTS_INTERFACE_CPP_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/interface/main.cpp") @@ -55,15 +46,24 @@ set(LIBLOOT_SRC_TESTS_INTERFACE_H_FILES "${CMAKE_SOURCE_DIR}/src/tests/api/interface/create_game_handle_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/interface/database_interface_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/interface/game_interface_test.h" - "${CMAKE_SOURCE_DIR}/src/tests/api/interface/is_compatible_test.h") + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/is_compatible_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/conditional_metadata_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/file_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/group_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/location_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/message_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/message_content_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/plugin_cleaning_data_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/plugin_metadata_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/interface/metadata/tag_test.h") source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" PREFIX "Source Files" FILES ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES}) -source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" - PREFIX "Header Files" - FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) +# source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/internals" +# PREFIX "Header Files" +# FILES ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES}) source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" PREFIX "Source Files" @@ -77,7 +77,7 @@ source_group(TREE "${CMAKE_SOURCE_DIR}/src/tests/api/interface" set(LIBLOOT_INTERNALS_TESTS_ALL_SOURCES ${LIBLOOT_ALL_SOURCES} ${LIBLOOT_SRC_TESTS_INTERNALS_CPP_FILES} - ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} + # ${LIBLOOT_SRC_TESTS_INTERNALS_H_FILES} "${CMAKE_SOURCE_DIR}/src/tests/common_game_test_fixture.h" "${CMAKE_SOURCE_DIR}/src/tests/test_helpers.h" "${CMAKE_SOURCE_DIR}/src/tests/printers.h") diff --git a/cpp/include/loot/enum/game_type.h b/cpp/include/loot/enum/game_type.h index ccec865c..8a1edc01 100644 --- a/cpp/include/loot/enum/game_type.h +++ b/cpp/include/loot/enum/game_type.h @@ -52,7 +52,9 @@ enum struct GameType : unsigned int { /** Starfield */ starfield, /** OpenMW */ - openmw + openmw, + /** The Elder Scrolls IV: Oblivion Remastered */ + oblivionRemastered }; } diff --git a/cpp/include/loot/loot_version.h b/cpp/include/loot/loot_version.h index 961407b0..55e78fc7 100644 --- a/cpp/include/loot/loot_version.h +++ b/cpp/include/loot/loot_version.h @@ -37,7 +37,7 @@ inline constexpr unsigned int LIBLOOT_VERSION_MAJOR = 0; inline constexpr unsigned int LIBLOOT_VERSION_MINOR = 26; /** @brief libloot's patch version number. */ -inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 0; +inline constexpr unsigned int LIBLOOT_VERSION_PATCH = 1; /** * @brief Get the library version. diff --git a/cpp/src/api/convert.cpp b/cpp/src/api/convert.cpp index ee48b0b2..a4f34b07 100644 --- a/cpp/src/api/convert.cpp +++ b/cpp/src/api/convert.cpp @@ -1,6 +1,6 @@ #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace loot { // To public types diff --git a/cpp/src/api/database.cpp b/cpp/src/api/database.cpp index d5620ae9..d0a174b8 100644 --- a/cpp/src/api/database.cpp +++ b/cpp/src/api/database.cpp @@ -2,7 +2,7 @@ #include "api/database.h" #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace loot { Database::Database(::rust::Box&& database) : diff --git a/cpp/src/api/sorting/cyclic_interaction_error.cpp b/cpp/src/api/exception/cyclic_interaction_error.cpp similarity index 100% rename from cpp/src/api/sorting/cyclic_interaction_error.cpp rename to cpp/src/api/exception/cyclic_interaction_error.cpp diff --git a/cpp/src/api/error_categories.cpp b/cpp/src/api/exception/error_categories.cpp similarity index 100% rename from cpp/src/api/error_categories.cpp rename to cpp/src/api/exception/error_categories.cpp diff --git a/cpp/src/api/exception.cpp b/cpp/src/api/exception/exception.cpp similarity index 99% rename from cpp/src/api/exception.cpp rename to cpp/src/api/exception/exception.cpp index 1292271f..73c0bfff 100644 --- a/cpp/src/api/exception.cpp +++ b/cpp/src/api/exception/exception.cpp @@ -1,4 +1,4 @@ -#include "api/exception.h" +#include "api/exception/exception.h" #include diff --git a/cpp/src/api/exception.h b/cpp/src/api/exception/exception.h similarity index 100% rename from cpp/src/api/exception.h rename to cpp/src/api/exception/exception.h diff --git a/cpp/src/api/sorting/undefined_group_error.cpp b/cpp/src/api/exception/undefined_group_error.cpp similarity index 100% rename from cpp/src/api/sorting/undefined_group_error.cpp rename to cpp/src/api/exception/undefined_group_error.cpp diff --git a/cpp/src/api/game.cpp b/cpp/src/api/game.cpp index 50192171..34f05e58 100644 --- a/cpp/src/api/game.cpp +++ b/cpp/src/api/game.cpp @@ -2,7 +2,7 @@ #include "api/game.h" #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace { loot::GameType convert(loot::rust::GameType gameType) { @@ -29,6 +29,8 @@ loot::GameType convert(loot::rust::GameType gameType) { return loot::GameType::starfield; case loot::rust::GameType::OpenMW: return loot::GameType::openmw; + case loot::rust::GameType::OblivionRemastered: + return loot::GameType::oblivionRemastered; default: throw std::logic_error("Unsupported GameType value"); } @@ -58,6 +60,8 @@ loot::rust::GameType convert(loot::GameType gameType) { return loot::rust::GameType::Starfield; case loot::GameType::openmw: return loot::rust::GameType::OpenMW; + case loot::GameType::oblivionRemastered: + return loot::rust::GameType::OblivionRemastered; default: throw std::logic_error("Unsupported GameType value"); } diff --git a/cpp/src/api/game.h b/cpp/src/api/game.h index 58c4e72c..7422282b 100644 --- a/cpp/src/api/game.h +++ b/cpp/src/api/game.h @@ -22,7 +22,7 @@ public: GameType GetType() const override; - std::vector GetAdditionalDataPaths() const; + std::vector GetAdditionalDataPaths() const override; void SetAdditionalDataPaths( const std::vector& additionalDataPaths) override; diff --git a/cpp/src/api/plugin.cpp b/cpp/src/api/plugin.cpp index 08c383dc..135c703d 100644 --- a/cpp/src/api/plugin.cpp +++ b/cpp/src/api/plugin.cpp @@ -4,7 +4,7 @@ #include #include "api/convert.h" -#include "api/exception.h" +#include "api/exception/exception.h" namespace loot { Plugin::Plugin(::rust::Box plugin) : @@ -101,9 +101,7 @@ bool Plugin::DoRecordsOverlap(const PluginInterface& plugin) const { return plugin_->do_records_overlap(*otherPlugin.plugin_); } catch (std::bad_cast&) { - throw std::invalid_argument( - "Tried to check if records overlapped with a different concrete type " - "implementing PluginInterface"); + return false; } catch (const ::rust::Error& e) { std::rethrow_exception(mapError(e)); } diff --git a/cpp/src/api/resource.rc b/cpp/src/api/resource.rc index a119a2db..8c9d7a4d 100644 --- a/cpp/src/api/resource.rc +++ b/cpp/src/api/resource.rc @@ -13,12 +13,12 @@ BLOCK "040904b0" BEGIN VALUE "CompanyName", "LOOT" VALUE "FileDescription", "Library providing LOOT's core functionality" -VALUE "FileVersion", "0.26.0" +VALUE "FileVersion", "0.26.1" VALUE "InternalName", "loot" VALUE "LegalCopyright", "Copyright (C) 2013-2022 Oliver Hamlet" VALUE "OriginalFilename", "loot.dll" VALUE "ProductName", "LOOT" -VALUE "ProductVersion", "0.26.0" +VALUE "ProductVersion", "0.26.1" END END BLOCK "VarFileInfo" diff --git a/cpp/src/game.rs b/cpp/src/game.rs index 85c29594..50312d77 100644 --- a/cpp/src/game.rs +++ b/cpp/src/game.rs @@ -21,6 +21,7 @@ impl TryFrom for GameType { libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } @@ -42,6 +43,7 @@ impl TryFrom for libloot::GameType { GameType::Morrowind => Ok(libloot::GameType::Morrowind), GameType::Starfield => Ok(libloot::GameType::Starfield), GameType::OpenMW => Ok(libloot::GameType::OpenMW), + GameType::OblivionRemastered => Ok(libloot::GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs index 61f2161b..b1e3f141 100644 --- a/cpp/src/lib.rs +++ b/cpp/src/lib.rs @@ -232,6 +232,7 @@ mod ffi { Morrowind, Starfield, OpenMW, + OblivionRemastered, } pub enum MessageType { diff --git a/cpp/src/tests/api/interface/create_game_handle_test.h b/cpp/src/tests/api/interface/create_game_handle_test.h index 07b7a01b..80539b9b 100644 --- a/cpp/src/tests/api/interface/create_game_handle_test.h +++ b/cpp/src/tests/api/interface/create_game_handle_test.h @@ -88,17 +88,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, CreateGameHandleTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se, - GameType::fo4vr, - GameType::tes5vr, - GameType::tes3, - GameType::starfield, - GameType::openmw)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(CreateGameHandleTest, shouldSucceedIfPassedValidParametersWithRelativePaths) { @@ -151,6 +141,74 @@ TEST_P(CreateGameHandleTest, EXPECT_TRUE(handle_); } #endif + +#ifndef _WIN32 +TEST_P( + CreateGameHandleTest, + shouldThrowOnLinuxIfLocalPathIsNotGivenExceptForMorrowindOpenMWAndOblivionRemastered) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::oblivionRemastered) { + EXPECT_NO_THROW(CreateGameHandle(GetParam(), gamePath)); + } else { + EXPECT_THROW(CreateGameHandle(GetParam(), gamePath), std::system_error); + } +} +#else +TEST_P(CreateGameHandleTest, + shouldNotThrowOnWindowsIfLocalPathIsNotGiven) { + EXPECT_NO_THROW(CreateGameHandle(GetParam(), gamePath)); +} +#endif + +TEST_P(CreateGameHandleTest, + shouldNotThrowIfGameAndLocalPathsAreNotEmpty) { + EXPECT_NO_THROW(CreateGameHandle(GetParam(), gamePath, localPath)); +} + +TEST_P( + CreateGameHandleTest, + shouldSetAdditionalDataPathsForFallout4FromMicrosoftStoreOrStarfield) { + if (GetParam() == GameType::fo4) { + // Create the file that indicates it's a Microsoft Store install. + touch(gamePath / "appxmanifest.xml"); + } else if (GetParam() == GameType::openmw) { + std::ofstream out(gamePath / "openmw.cfg"); + out << "data-local=\"" << (localPath / "data").u8string() << "\"" + << std::endl + << "config=\"" << localPath.u8string() << "\""; + } + + const auto game = CreateGameHandle(GetParam(), gamePath, localPath); + + if (GetParam() == GameType::fo4) { + const auto basePath = gamePath / ".." / ".."; + EXPECT_EQ(std::vector( + {basePath / "Fallout 4- Automatron (PC)" / "Content" / "Data", + basePath / "Fallout 4- Nuka-World (PC)" / "Content" / "Data", + basePath / "Fallout 4- Wasteland Workshop (PC)" / "Content" / + "Data", + basePath / "Fallout 4- High Resolution Texture Pack" / + "Content" / "Data", + basePath / "Fallout 4- Vault-Tec Workshop (PC)" / "Content" / + "Data", + basePath / "Fallout 4- Far Harbor (PC)" / "Content" / "Data", + basePath / "Fallout 4- Contraptions Workshop (PC)" / + "Content" / "Data"}), + game->GetAdditionalDataPaths()); + } else if (GetParam() == GameType::starfield) { + ASSERT_EQ(1, game->GetAdditionalDataPaths().size()); + + const auto expectedSuffix = std::filesystem::u8path("Documents") / + "My Games" / "Starfield" / "Data"; + EXPECT_TRUE(endsWith(game->GetAdditionalDataPaths()[0].u8string(), + expectedSuffix.u8string())); + } else if (GetParam() == GameType::openmw) { + EXPECT_EQ(std::vector{localPath / "data"}, + game->GetAdditionalDataPaths()); + } else { + EXPECT_TRUE(game->GetAdditionalDataPaths().empty()); + } +} } } diff --git a/cpp/src/tests/api/interface/database_interface_test.h b/cpp/src/tests/api/interface/database_interface_test.h index 2ee00b95..a6aad0ff 100644 --- a/cpp/src/tests/api/interface/database_interface_test.h +++ b/cpp/src/tests/api/interface/database_interface_test.h @@ -113,12 +113,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, DatabaseInterfaceTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(DatabaseInterfaceTest, loadMasterlistShouldSucceedEvenIfGameHandleIsDiscarded) { @@ -275,7 +270,7 @@ TEST_P(DatabaseInterfaceTest, writeUserMetadataShouldShouldWriteUserMetadata) { } TEST_P(DatabaseInterfaceTest, evaluateShouldReturnTrueIfTheConditionIsTrue) { - EXPECT_TRUE(handle_->GetDatabase().Evaluate("file(\"Blank.esp\")")); + EXPECT_TRUE(handle_->GetDatabase().Evaluate("file(\"Blank.esp\")")); } TEST_P(DatabaseInterfaceTest, evaluateShouldReturnFalseIfTheConditionIsFalse) { diff --git a/cpp/src/tests/api/interface/game_interface_test.h b/cpp/src/tests/api/interface/game_interface_test.h index f5c699bf..b1c72236 100644 --- a/cpp/src/tests/api/interface/game_interface_test.h +++ b/cpp/src/tests/api/interface/game_interface_test.h @@ -30,6 +30,8 @@ along with LOOT. If not, see namespace loot { namespace test { +constexpr unsigned int ESP_ERROR_PLUGIN_METADATA_NOT_FOUND = 14; + class GameInterfaceTest : public ApiGameOperationsTest { protected: GameInterfaceTest() : @@ -74,17 +76,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, GameInterfaceTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se, - GameType::fo4vr, - GameType::tes5vr, - GameType::tes3, - GameType::starfield, - GameType::openmw)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldDoThat) { const auto paths = std::vector{ @@ -95,6 +87,56 @@ TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldDoThat) { EXPECT_EQ(paths, handle_->GetAdditionalDataPaths()); } +TEST_P(GameInterfaceTest, setAdditionalDataPathsShouldClearTheConditionCache) { + PluginMetadata metadata(blankEsm); + metadata.SetLoadAfterFiles({File("plugin.esp", "", "file(\"plugin.esp\")")}); + handle_->GetDatabase().SetPluginUserMetadata(metadata); + + auto evaluatedMetadata = + handle_->GetDatabase().GetPluginUserMetadata(blankEsm, true); + EXPECT_FALSE(evaluatedMetadata.has_value()); + + const auto dataFilePath = gamePath.parent_path() / "Data" / "plugin.esp"; + touch(dataFilePath); + handle_->SetAdditionalDataPaths({dataFilePath.parent_path()}); + + evaluatedMetadata = + handle_->GetDatabase().GetPluginUserMetadata(blankEsm, true); + EXPECT_FALSE(evaluatedMetadata.value().GetLoadAfterFiles().empty()); +} + +TEST_P(GameInterfaceTest, + setAdditionalDataPathsShouldUpdateWhereLoadOrderPluginsAreFound) { + // Set no additional data paths to avoid picking up non-test plugins on PCs + // which have Starfield or Fallout 4 installed. + handle_->SetAdditionalDataPaths({}); + handle_->LoadCurrentLoadOrderState(); + auto loadOrder = handle_->GetLoadOrder(); + + const auto filename = "plugin.esp"; + const auto dataFilePath = gamePath.parent_path() / "Data" / filename; + std::filesystem::create_directories(dataFilePath.parent_path()); + std::filesystem::copy_file(getSourcePluginsPath() / blankEsp, dataFilePath); + ASSERT_TRUE(std::filesystem::exists(dataFilePath)); + + if (GetParam() == GameType::starfield) { + std::filesystem::copy_file(getSourcePluginsPath() / blankEsp, + dataPath / filename); + ASSERT_TRUE(std::filesystem::exists(dataPath / filename)); + } + + std::filesystem::last_write_time( + dataFilePath, + std::filesystem::file_time_type::clock().now() + std::chrono::hours(1)); + + handle_->SetAdditionalDataPaths({dataFilePath.parent_path()}); + handle_->LoadCurrentLoadOrderState(); + + loadOrder.push_back(filename); + + EXPECT_EQ(loadOrder, handle_->GetLoadOrder()); +} + TEST_P(GameInterfaceTest, isValidPluginShouldReturnTrueForAValidPlugin) { EXPECT_TRUE(handle_->IsValidPlugin(blankEsm)); } @@ -116,9 +158,37 @@ TEST_P(GameInterfaceTest, isValidPluginShouldReturnFalseForAnEmptyFile) { EXPECT_FALSE(handle_->IsValidPlugin(emptyFile)); } +TEST_P(GameInterfaceTest, + isValidPluginShouldResolveRelativePathsRelativeToDataPath) { + const auto path = ".." / dataPath.filename() / blankEsm; + + EXPECT_TRUE(handle_->IsValidPlugin(path)); +} + +TEST_P(GameInterfaceTest, isValidPluginShouldUseAbsolutePathsAsGiven) { + ASSERT_TRUE(dataPath.is_absolute()); + + const auto path = dataPath / std::filesystem::u8path(blankEsm); + + EXPECT_TRUE(handle_->IsValidPlugin(path)); +} + TEST_P( GameInterfaceTest, - loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfAllGivenPlugins) { + isValidPluginShouldTryGhostedPathIfGivenPluginDoesNotExistExceptForOpenMW) { + if (GetParam() == GameType::openmw) { + // This wasn't done for OpenMW during common setup. + const auto pluginPath = dataPath / (blankMasterDependentEsm + ".ghost"); + std::filesystem::rename(dataPath / blankMasterDependentEsm, pluginPath); + + EXPECT_FALSE(handle_->IsValidPlugin(blankMasterDependentEsm)); + } else { + EXPECT_TRUE(handle_->IsValidPlugin(blankMasterDependentEsm)); + } +} + +TEST_P(GameInterfaceTest, + loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfAllGivenPlugins) { handle_->LoadPlugins(pluginsToLoad, true); if (GetParam() == GameType::starfield) { EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); @@ -154,9 +224,29 @@ TEST_P(GameInterfaceTest, loadPluginsShouldTrimDotGhostFileExtensions) { EXPECT_EQ(blankMasterDependentEsm, plugin->GetName()); } +TEST_P(GameInterfaceTest, + loadPluginsWithHeadersOnlyTrueShouldLoadTheHeadersOfGivenPlugins) { + handle_->LoadPlugins(pluginsToLoad, true); + + if (GetParam() == GameType::starfield) { + EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); + } else { + EXPECT_EQ(11, handle_->GetLoadedPlugins().size()); + } + + // Check that one plugin's header has been read. + ASSERT_NO_THROW(handle_->GetPlugin(masterFile)); + auto plugin = handle_->GetPlugin(masterFile); + EXPECT_EQ("5.0", plugin->GetVersion().value()); + + // Check that only the header has been read. + EXPECT_FALSE(plugin->GetCRC()); +} + TEST_P(GameInterfaceTest, loadPluginsWithHeadersOnlyFalseShouldFullyLoadAllInstalledPlugins) { handle_->LoadPlugins(pluginsToLoad, false); + if (GetParam() == GameType::starfield) { EXPECT_EQ(6, handle_->GetLoadedPlugins().size()); } else { @@ -184,6 +274,211 @@ TEST_P(GameInterfaceTest, loadPluginsWithANonAsciiPluginShouldLoadIt) { EXPECT_EQ(blankEsmCrc, plugin->GetCRC().value()); } +TEST_P( + GameInterfaceTest, + loadPluginsShouldNotThrowIfAFilenameHasNonWindows1252EncodableCharacters) { + const auto pluginName = std::filesystem::u8path( + u8"\u2551\u00BB\u00C1\u2510\u2557\u00FE\u00C3\u00CE.esp"); + std::filesystem::copy(dataPath / blankEsp, dataPath / pluginName); + + EXPECT_NO_THROW(handle_->LoadPlugins({pluginName}, false)); +} + +TEST_P(GameInterfaceTest, + loadPluginsWithANonPluginShouldNotAddItToTheLoadedPlugins) { + ASSERT_THROW(handle_->LoadPlugins({nonPluginFile}, false), + std::invalid_argument); + + ASSERT_TRUE(handle_->GetLoadedPlugins().empty()); +} + +TEST_P(GameInterfaceTest, + loadPluginsWithAnInvalidPluginShouldNotAddItToTheLoadedPlugins) { + ASSERT_FALSE(std::filesystem::exists(dataPath / invalidPlugin)); + ASSERT_NO_THROW(std::filesystem::copy_file(dataPath / blankEsm, + dataPath / invalidPlugin)); + ASSERT_TRUE(std::filesystem::exists(dataPath / invalidPlugin)); + std::ofstream out(dataPath / invalidPlugin, std::fstream::app); + out << "GRUP0"; + out.close(); + + ASSERT_NO_THROW(handle_->LoadPlugins({invalidPlugin}, false)); + + ASSERT_TRUE(handle_->GetLoadedPlugins().empty()); +} + +TEST_P(GameInterfaceTest, loadPluginsShouldNotClearThePluginsCache) { + handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); + ASSERT_EQ(1, handle_->GetLoadedPlugins().size()); + + handle_->LoadPlugins({std::filesystem::u8path(blankEsp)}, true); + + EXPECT_EQ(2, handle_->GetLoadedPlugins().size()); +} + +TEST_P(GameInterfaceTest, + loadPluginsShouldReplaceCacheEntriesForTheGivenPlugins) { + handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); + const auto pointer = handle_->GetPlugin(blankEsm); + ASSERT_NE(nullptr, pointer); + + handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, false); + + const auto newPointer = handle_->GetPlugin(blankEsm); + ASSERT_NE(nullptr, newPointer); + EXPECT_NE(pointer, newPointer); +} + +TEST_P(GameInterfaceTest, + loadPluginsShouldThrowIfGivenVectorElementsWithTheSameFilename) { + const auto dataPluginPath = dataPath / std::filesystem::u8path(blankEsm); + const auto sourcePluginPath = + getSourcePluginsPath() / std::filesystem::u8path(blankEsm); + + EXPECT_THROW(handle_->LoadPlugins(std::vector( + {dataPluginPath, sourcePluginPath}), + true), + std::invalid_argument); +} + +TEST_P(GameInterfaceTest, + loadPluginsShouldResolveRelativePathsRelativeToDataPath) { + const auto relativePath = ".." / dataPath.filename() / blankEsm; + + handle_->LoadPlugins(std::vector({relativePath}), + true); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankEsm)); +} + +TEST_P(GameInterfaceTest, loadPluginsShouldUseAbsolutePathsAsGiven) { + const auto absolutePath = dataPath / std::filesystem::u8path(blankEsm); + + handle_->LoadPlugins(std::vector({absolutePath}), + true); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankEsm)); +} + +TEST_P( + GameInterfaceTest, + loadPluginsShouldThrowIfFullyLoadingAPluginWithAMissingMasterIfGameIsMorrowindOrStarfield) { + const auto pluginName = + GetParam() == GameType::starfield ? blankFullEsm : blankEsm; + + std::filesystem::remove(dataPath / pluginName); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) { + try { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + FAIL(); + } catch (const std::system_error& e) { + EXPECT_EQ(ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, e.code().value()); + EXPECT_EQ(esplugin_category(), e.code().category()); + } + } else { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankMasterDependentEsm)); + } +} + +TEST_P( + GameInterfaceTest, + loadPluginsShouldThrowIfAPluginHasAMasterThatIsNotInTheInputAndIsNotAlreadyLoadedAndGameIsMorrowindOrStarfield) { + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) { + try { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + FAIL(); + } catch (const std::system_error& e) { + EXPECT_EQ(ESP_ERROR_PLUGIN_METADATA_NOT_FOUND, e.code().value()); + EXPECT_EQ(esplugin_category(), e.code().category()); + } + } else { + handle_->LoadPlugins({blankMasterDependentEsm}, false); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankMasterDependentEsm)); + } +} + +TEST_P( + GameInterfaceTest, + loadPluginsShouldNotThrowIfAPluginHasAMasterThatIsNotInTheInputButIsAlreadyLoaded) { + const auto pluginName = + GetParam() == GameType::starfield ? blankFullEsm : blankEsm; + + handle_->LoadPlugins({pluginName}, true); + + handle_->LoadPlugins({blankMasterDependentEsm}, false); + + EXPECT_NE(nullptr, handle_->GetPlugin(blankMasterDependentEsm)); +} + +TEST_P(GameInterfaceTest, + sortPluginsWithNoLoadedPluginsShouldReturnAnEmptyList) { + const auto sorted = handle_->SortPlugins(handle_->GetLoadOrder()); + + EXPECT_TRUE(sorted.empty()); +} + +TEST_P(GameInterfaceTest, sortPluginsShouldOnlySortTheGivenPlugins) { + handle_->LoadPlugins(GetInstalledPlugins(), false); + + std::vector plugins{blankEsp, blankDifferentEsp}; + const auto sorted = handle_->SortPlugins(plugins); + + EXPECT_EQ(plugins, sorted); +} + +TEST_P(GameInterfaceTest, + sortingShouldNotMakeUnnecessaryChangesToAnExistingLoadOrder) { + std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); + + handle_->LoadCurrentLoadOrderState(); + + auto plugins = GetInstalledPlugins(); + handle_->LoadPlugins({plugins.front()}, true); + plugins.erase(plugins.begin()); + handle_->LoadPlugins(plugins, false); + + std::vector expectedSortedOrder; + if (GetParam() == GameType::openmw) { + // The existing load order for OpenMW doesn't have plugins loading after + // their masters, because the game doesn't enforce that, and the test + // setup cannot enforce the positions of inactive plugins. + expectedSortedOrder = { + blankDifferentEsm, + blankDifferentMasterDependentEsm, + blankDifferentEsp, + blankDifferentPluginDependentEsp, + blankEsm, + blankMasterDependentEsm, + blankMasterDependentEsp, + blankEsp, + blankPluginDependentEsp, + masterFile, + blankDifferentMasterDependentEsp, + }; + } else { + expectedSortedOrder = getLoadOrder(); + } + + // Check stability by running the sort 100 times. + for (int i = 0; i < 100; i++) { + auto input = handle_->GetLoadOrder(); + auto sorted = handle_->SortPlugins(handle_->GetLoadOrder()); + ASSERT_EQ(expectedSortedOrder, sorted) << " for sort " << i; + } +} + +TEST_P(GameInterfaceTest, sortPluginsShouldThrowIfAGivenPluginIsNotLoaded) { + std::vector plugins{blankEsp, blankDifferentEsp}; + + EXPECT_THROW(handle_->SortPlugins(plugins), std::runtime_error); +} + TEST_P(GameInterfaceTest, clearLoadedPluginsShouldClearThePluginsCache) { handle_->LoadPlugins({std::filesystem::u8path(blankEsm)}, true); const auto pointer = handle_->GetPlugin(blankEsm); @@ -255,18 +550,54 @@ TEST_P(GameInterfaceTest, sortPluginsShouldSucceedIfPassedValidArguments) { } TEST_P(GameInterfaceTest, - isPluginActiveShouldReturnFalseIfTheGivenPluginIsNotActive) { + isPluginActiveShouldReturnTrueIfTheGivenPluginIsActive) { handle_->LoadCurrentLoadOrderState(); EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); } TEST_P(GameInterfaceTest, - isPluginActiveShouldReturnTrueIfTheGivenPluginIsActive) { + isPluginActiveShouldReturnFalseIfTheGivenPluginIsNotActive) { handle_->LoadCurrentLoadOrderState(); EXPECT_FALSE(handle_->IsPluginActive(blankEsp)); } +TEST_P(GameInterfaceTest, + isPluginActiveShouldActivePluginAsActiveWithHeaderLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsm}, true)); + + EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldInactivePluginAsInactiveWithHeaderLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsp}, true)); + + EXPECT_FALSE(handle_->IsPluginActive(blankEsp)); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldActivePluginAsActiveWhenFullyLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsm}, false)); + + EXPECT_TRUE(handle_->IsPluginActive(blankEsm)); +} + +TEST_P(GameInterfaceTest, + isPluginActiveShouldInactivePluginAsInactiveWhenFullyLoaded) { + handle_->LoadCurrentLoadOrderState(); + + ASSERT_NO_THROW(handle_->LoadPlugins({blankEsp}, false)); + + EXPECT_FALSE(handle_->IsPluginActive(blankEsp)); +} + TEST_P(GameInterfaceTest, getLoadOrderShouldReturnTheCurrentLoadOrder) { // Remove the non-ASCII duplicate plugin. std::filesystem::remove(dataPath / std::filesystem::u8path(nonAsciiEsm)); diff --git a/cpp/src/tests/api/interface/main.cpp b/cpp/src/tests/api/interface/main.cpp index 29027fef..afd21763 100644 --- a/cpp/src/tests/api/interface/main.cpp +++ b/cpp/src/tests/api/interface/main.cpp @@ -25,10 +25,20 @@ #include #include "loot/api.h" +#include "tests/api/interface/metadata/conditional_metadata_test.h" +#include "tests/api/interface/metadata/file_test.h" +#include "tests/api/interface/metadata/group_test.h" +#include "tests/api/interface/metadata/location_test.h" +#include "tests/api/interface/metadata/message_content_test.h" +#include "tests/api/interface/metadata/message_test.h" +#include "tests/api/interface/metadata/plugin_cleaning_data_test.h" +#include "tests/api/interface/metadata/plugin_metadata_test.h" +#include "tests/api/interface/metadata/tag_test.h" #include "tests/api/interface/create_game_handle_test.h" #include "tests/api/interface/database_interface_test.h" #include "tests/api/interface/game_interface_test.h" #include "tests/api/interface/is_compatible_test.h" +#include "tests/api/interface/plugin_interface_test.h" int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/cpp/src/tests/api/internals/metadata/conditional_metadata_test.h b/cpp/src/tests/api/interface/metadata/conditional_metadata_test.h similarity index 81% rename from cpp/src/tests/api/internals/metadata/conditional_metadata_test.h rename to cpp/src/tests/api/interface/metadata/conditional_metadata_test.h index 4fdf0012..6f8b4804 100644 --- a/cpp/src/tests/api/internals/metadata/conditional_metadata_test.h +++ b/cpp/src/tests/api/interface/metadata/conditional_metadata_test.h @@ -22,8 +22,8 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_CONDITIONAL_METADATA_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_CONDITIONAL_METADATA_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_CONDITIONAL_METADATA_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_CONDITIONAL_METADATA_TEST #include "loot/metadata/conditional_metadata.h" #include "tests/common_game_test_fixture.h" @@ -41,12 +41,7 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_SUITE_P(, ConditionalMetadataTest, - ::testing::Values(GameType::tes4, - GameType::tes5, - GameType::fo3, - GameType::fonv, - GameType::fo4, - GameType::tes5se)); + ::testing::ValuesIn(ALL_GAME_TYPES)); TEST_P(ConditionalMetadataTest, defaultConstructorShouldSetEmptyConditionString) { diff --git a/cpp/src/tests/api/internals/metadata/file_test.h b/cpp/src/tests/api/interface/metadata/file_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/file_test.h rename to cpp/src/tests/api/interface/metadata/file_test.h index e1ae3a2d..16aa2e1d 100644 --- a/cpp/src/tests/api/internals/metadata/file_test.h +++ b/cpp/src/tests/api/interface/metadata/file_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_FILE_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_FILE_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_FILE_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_FILE_TEST #include #include "loot/metadata/file.h" -namespace loot { -namespace test { +namespace loot::test { TEST(File, defaultConstructorShouldInitialiseEmptyStrings) { File file; @@ -381,6 +380,5 @@ TEST(File, getDisplayNameShouldReturnDisplayString) { EXPECT_EQ("display", file.GetDisplayName()); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/group_test.h b/cpp/src/tests/api/interface/metadata/group_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/group_test.h rename to cpp/src/tests/api/interface/metadata/group_test.h index ae670152..f3cd21fb 100644 --- a/cpp/src/tests/api/internals/metadata/group_test.h +++ b/cpp/src/tests/api/interface/metadata/group_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_GROUP_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_GROUP_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_GROUP_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_GROUP_TEST #include #include "loot/metadata/group.h" -namespace loot { -namespace test { +namespace loot::test { TEST(Group, defaultConstructorShouldCreateDefaultGroup) { Group group; @@ -350,6 +349,5 @@ TEST( EXPECT_TRUE(group2 >= group1); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/location_test.h b/cpp/src/tests/api/interface/metadata/location_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/location_test.h rename to cpp/src/tests/api/interface/metadata/location_test.h index 6d9d3bcd..3a00cc5f 100644 --- a/cpp/src/tests/api/internals/metadata/location_test.h +++ b/cpp/src/tests/api/interface/metadata/location_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_LOCATION_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_LOCATION_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_LOCATION_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_LOCATION_TEST #include #include "loot/metadata/location.h" -namespace loot { -namespace test { +namespace loot::test { TEST(Location, defaultConstructorShouldInitialiseEmptyStrings) { Location location; @@ -234,6 +233,5 @@ TEST( EXPECT_FALSE(location1 >= location2); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/message_content_test.h b/cpp/src/tests/api/interface/metadata/message_content_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/message_content_test.h rename to cpp/src/tests/api/interface/metadata/message_content_test.h index f5f20cfb..00826bf8 100644 --- a/cpp/src/tests/api/internals/metadata/message_content_test.h +++ b/cpp/src/tests/api/interface/metadata/message_content_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_CONTENT_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_CONTENT_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_CONTENT_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_CONTENT_TEST #include #include "loot/metadata/message_content.h" -namespace loot { -namespace test { +namespace loot::test { const std::string french = "fr"; TEST(MessageContent, defaultConstructorShouldSetEmptyEnglishLanguageString) { @@ -330,6 +329,5 @@ TEST( EXPECT_EQ("test3", content.value().GetText()); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/message_test.h b/cpp/src/tests/api/interface/metadata/message_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/message_test.h rename to cpp/src/tests/api/interface/metadata/message_test.h index 8939440f..b55bc196 100644 --- a/cpp/src/tests/api/internals/metadata/message_test.h +++ b/cpp/src/tests/api/interface/metadata/message_test.h @@ -22,14 +22,13 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_MESSAGE_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_MESSAGE_TEST #include "loot/metadata/message.h" #include "tests/common_game_test_fixture.h" -namespace loot { -namespace test { +namespace loot::test { class MessageTest : public CommonGameTestFixture { protected: MessageTest() : CommonGameTestFixture(GameType::tes4) {} @@ -342,6 +341,5 @@ TEST_F( EXPECT_TRUE(message2 >= message1); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h b/cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h rename to cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h index 890e5cc7..0d8a6d9c 100644 --- a/cpp/src/tests/api/internals/metadata/plugin_cleaning_data_test.h +++ b/cpp/src/tests/api/interface/metadata/plugin_cleaning_data_test.h @@ -22,14 +22,13 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_CLEANING_DATA -#define LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_CLEANING_DATA +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_CLEANING_DATA_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_CLEANING_DATA_TEST #include "loot/metadata/plugin_cleaning_data.h" #include "tests/common_game_test_fixture.h" -namespace loot { -namespace test { +namespace loot::test { class PluginCleaningDataTest : public CommonGameTestFixture { protected: PluginCleaningDataTest() : @@ -310,6 +309,5 @@ TEST_F( EXPECT_TRUE(info2 > info1); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/plugin_metadata_test.h b/cpp/src/tests/api/interface/metadata/plugin_metadata_test.h similarity index 98% rename from cpp/src/tests/api/internals/metadata/plugin_metadata_test.h rename to cpp/src/tests/api/interface/metadata/plugin_metadata_test.h index 4629c3de..fd4a97fe 100644 --- a/cpp/src/tests/api/internals/metadata/plugin_metadata_test.h +++ b/cpp/src/tests/api/interface/metadata/plugin_metadata_test.h @@ -22,14 +22,13 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_METADATA_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_PLUGIN_METADATA_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_METADATA_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_PLUGIN_METADATA_TEST #include "loot/metadata/plugin_metadata.h" #include "tests/common_game_test_fixture.h" -namespace loot { -namespace test { +namespace loot::test { class PluginMetadataTest : public CommonGameTestFixture { protected: PluginMetadataTest() : @@ -392,6 +391,5 @@ TEST_F(PluginMetadataTest, plugin.AsYaml()); } } -} #endif diff --git a/cpp/src/tests/api/internals/metadata/tag_test.h b/cpp/src/tests/api/interface/metadata/tag_test.h similarity index 97% rename from cpp/src/tests/api/internals/metadata/tag_test.h rename to cpp/src/tests/api/interface/metadata/tag_test.h index e7793a4d..39836935 100644 --- a/cpp/src/tests/api/internals/metadata/tag_test.h +++ b/cpp/src/tests/api/interface/metadata/tag_test.h @@ -22,15 +22,14 @@ along with LOOT. If not, see . */ -#ifndef LOOT_TESTS_API_INTERNALS_METADATA_TAG_TEST -#define LOOT_TESTS_API_INTERNALS_METADATA_TAG_TEST +#ifndef LOOT_TESTS_API_INTERFACE_METADATA_TAG_TEST +#define LOOT_TESTS_API_INTERFACE_METADATA_TAG_TEST #include #include "loot/metadata/tag.h" -namespace loot { -namespace test { +namespace loot::test { TEST(Tag, defaultConstructorShouldSetEmptyNameAndConditionStringsForATagAddition) { Tag tag; @@ -284,6 +283,5 @@ TEST( EXPECT_TRUE(tag2 >= tag1); } } -} #endif diff --git a/cpp/src/tests/api/interface/plugin_interface_test.h b/cpp/src/tests/api/interface/plugin_interface_test.h new file mode 100644 index 00000000..ed8da8ba --- /dev/null +++ b/cpp/src/tests/api/interface/plugin_interface_test.h @@ -0,0 +1,486 @@ +/* LOOT + +A load order optimisation tool for Oblivion, Skyrim, Fallout 3 and +Fallout: New Vegas. + +Copyright (C) 2014-2016 WrinklyNinja + +This file is part of LOOT. + +LOOT is free software: you can redistribute +it and/or modify it under the terms of the GNU General Public License +as published by the Free Software Foundation, either version 3 of +the License, or (at your option) any later version. + +LOOT is distributed in the hope that it will +be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with LOOT. If not, see +. +*/ + +#ifndef LOOT_TESTS_API_INTERFACE_PLUGIN_INTERFACE_TEST +#define LOOT_TESTS_API_INTERFACE_PLUGIN_INTERFACE_TEST + +#include "loot/api.h" +#include "tests/api/interface/api_game_operations_test.h" + +namespace loot::test { +class PluginInterfaceTest : public ApiGameOperationsTest { +protected: + PluginInterfaceTest() : + ApiGameOperationsTest(), + nonAsciiEsp(u8"non\u00C1scii.esp"), + otherNonAsciiEsp(u8"other non\u00C1scii.esp"), + blankArchive("Blank" + GetArchiveFileExtension(GetParam())), + blankSuffixArchive("Blank - Different - suffix" + + GetArchiveFileExtension(GetParam())) {} + + void SetUp() override { + ApiGameOperationsTest::SetUp(); + + handle_->LoadPlugins(GetInstalledPlugins(), false); + + if (!supportsLightPlugins(GetParam())) { + ASSERT_NO_THROW( + std::filesystem::copy(dataPath / blankEsp, dataPath / blankEsl)); + } + ASSERT_TRUE(std::filesystem::exists(dataPath / blankEsl)); + + // Make sure the plugins with non-ASCII filenames exists. + ASSERT_NO_THROW(std::filesystem::copy_file( + dataPath / blankEsp, dataPath / std::filesystem::u8path(nonAsciiEsp))); + ASSERT_NO_THROW(std::filesystem::copy_file( + dataPath / blankEsp, + dataPath / std::filesystem::u8path(otherNonAsciiEsp))); + + // Copy across archive files. + std::filesystem::path blankMasterDependentArchive; + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::starfield) { + copyPlugin(getSourceArchivesPath(GetParam()), "Blank - Main.ba2"); + copyPlugin(getSourceArchivesPath(GetParam()), "Blank - Textures.ba2"); + + blankMasterDependentArchive = "Blank - Master Dependent - Main.ba2"; + std::filesystem::copy_file( + getSourceArchivesPath(GetParam()) / "Blank - Main.ba2", + dataPath / blankMasterDependentArchive); + ASSERT_TRUE( + std::filesystem::exists(dataPath / blankMasterDependentArchive)); + } else if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { + touch(dataPath / blankArchive); + + blankMasterDependentArchive = "Blank - Master Dependent.bsa"; + touch(dataPath / blankMasterDependentArchive); + } else { + copyPlugin(getSourcePluginsPath(), blankArchive); + + // Also create a copy for Blank - Master Dependent.esp to test overlap. + blankMasterDependentArchive = "Blank - Master Dependent.bsa"; + std::filesystem::copy_file(getSourcePluginsPath() / blankArchive, + dataPath / blankMasterDependentArchive); + ASSERT_TRUE( + std::filesystem::exists(dataPath / blankMasterDependentArchive)); + } + + // Create dummy archive files. + touch(dataPath / blankSuffixArchive); + + auto nonAsciiArchivePath = + dataPath / std::filesystem::u8path(u8"non\u00E1scii" + + GetArchiveFileExtension(GetParam())); + touch(dataPath / nonAsciiArchivePath); + + auto nonAsciiPrefixArchivePath = + dataPath / std::filesystem::u8path(u8"other non\u00E1scii2 - suffix" + + GetArchiveFileExtension(GetParam())); + touch(dataPath / nonAsciiPrefixArchivePath); + } + + std::shared_ptr LoadPluginHeader( + std::string_view pluginName) { + handle_->LoadPlugins({std::filesystem::u8path(pluginName)}, true); + + return handle_->GetPlugin(pluginName); + } + + std::shared_ptr LoadPlugin( + std::string_view pluginName) { + handle_->LoadPlugins({std::filesystem::u8path(pluginName)}, false); + + return handle_->GetPlugin(pluginName); + } + + std::string nonAsciiEsp; + std::string otherNonAsciiEsp; + std::string blankArchive; + std::string blankSuffixArchive; + + std::shared_ptr game_; + +private: + static std::string GetArchiveFileExtension(const GameType gameType) { + if (gameType == GameType::fo4 || gameType == GameType::fo4vr || + gameType == GameType::starfield) + return ".ba2"; + else + return ".bsa"; + } +}; + +class TestPlugin : public PluginInterface { +public: + std::string GetName() const override { return ""; } + + std::optional GetHeaderVersion() const override { + return std::optional(); + } + + std::optional GetVersion() const override { + return std::optional(); + } + + std::vector GetMasters() const override { return {}; } + + std::vector GetBashTags() const override { return {}; } + + std::optional GetCRC() const override { + return std::optional(); + } + + bool IsMaster() const override { return false; } + + bool IsLightPlugin() const override { return false; } + + bool IsMediumPlugin() const override { return false; } + + bool IsUpdatePlugin() const override { return false; } + + bool IsBlueprintPlugin() const override { return false; } + + bool IsValidAsLightPlugin() const override { return false; } + + bool IsValidAsMediumPlugin() const override { return false; } + + bool IsValidAsUpdatePlugin() const override { return false; } + + bool IsEmpty() const override { return false; } + + bool LoadsArchive() const override { return false; } + + bool DoRecordsOverlap(const PluginInterface&) const override { return false; } +}; + +// Pass an empty first argument, as it's a prefix for the test instantation, +// but we only have the one so no prefix is necessary. +INSTANTIATE_TEST_SUITE_P(, + PluginInterfaceTest, + ::testing::ValuesIn(ALL_GAME_TYPES)); + +TEST_P(PluginInterfaceTest, + shouldBeAbleToGetHeaderDataFromPluginLoadedHeaderOnly) { + const auto plugin = LoadPluginHeader(blankEsm); + + EXPECT_EQ(blankEsm, plugin->GetName()); + EXPECT_TRUE(plugin->GetMasters().empty()); + if (GetParam() == GameType::openmw || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FALSE(plugin->IsMaster()); + } else { + EXPECT_TRUE(plugin->IsMaster()); + } + EXPECT_FALSE(plugin->IsEmpty()); + EXPECT_EQ("5.0", plugin->GetVersion()); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { + EXPECT_FLOAT_EQ(1.2f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::tes4 || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FLOAT_EQ(0.8f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::starfield) { + EXPECT_FLOAT_EQ(0.96f, plugin->GetHeaderVersion().value()); + } else { + EXPECT_FLOAT_EQ(0.94f, plugin->GetHeaderVersion().value()); + } +} + +TEST_P(PluginInterfaceTest, shouldBeAbleToGetAllDataFromFullyLoadedPlugin) { + const auto plugin = LoadPlugin(blankEsm); + + EXPECT_EQ(blankEsm, plugin->GetName()); + EXPECT_TRUE(plugin->GetMasters().empty()); + if (GetParam() == GameType::openmw || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FALSE(plugin->IsMaster()); + } else { + EXPECT_TRUE(plugin->IsMaster()); + } + EXPECT_FALSE(plugin->IsEmpty()); + EXPECT_EQ("5.0", plugin->GetVersion()); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) { + EXPECT_FLOAT_EQ(1.2f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::tes4 || + GetParam() == GameType::oblivionRemastered) { + EXPECT_FLOAT_EQ(0.8f, plugin->GetHeaderVersion().value()); + } else if (GetParam() == GameType::starfield) { + EXPECT_FLOAT_EQ(0.96f, plugin->GetHeaderVersion().value()); + } else { + EXPECT_FLOAT_EQ(0.94f, plugin->GetHeaderVersion().value()); + } + + EXPECT_EQ(blankEsmCrc, plugin->GetCRC()); +} + +TEST_P(PluginInterfaceTest, + loadingANonMasterPluginShouldReadTheMasterFlagAsFalse) { + const auto plugin = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin->IsMaster()); +} + +TEST_P( + PluginInterfaceTest, + isLightPluginShouldBeTrueForAPluginWithEslFileExtensionForFallout4AndSkyrimSe) { + const auto plugin1 = LoadPluginHeader(blankEsm); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin1->IsLightPlugin()); + EXPECT_FALSE(plugin2->IsLightPlugin()); + + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield) { + const auto plugin3 = LoadPluginHeader(blankEsl); + EXPECT_TRUE(plugin3->IsLightPlugin()); + } +} + +TEST_P(PluginInterfaceTest, + isMediumPluginShouldBeTrueForAMediumFlaggedPluginForStarfield) { + if (GetParam() != GameType::starfield) { + auto bytes = ReadFile(dataPath / blankEsm); + bytes[9] = 0x4; + WriteFile(dataPath / blankEsm, bytes); + } + + const auto& pluginName = + GetParam() == GameType::starfield ? blankMediumEsm : blankEsm; + const auto plugin = LoadPluginHeader(pluginName); + + EXPECT_EQ(GetParam() == GameType::starfield, plugin->IsMediumPlugin()); +} + +TEST_P(PluginInterfaceTest, + isUpdatePluginShouldOnlyBeTrueForAStarfieldUpdatePlugin) { + auto bytes = ReadFile(dataPath / blankMasterDependentEsp); + bytes[9] = 0x2; + WriteFile(dataPath / blankMasterDependentEsp, bytes); + + const auto plugin1 = LoadPluginHeader(blankEsp); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin1->IsUpdatePlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, plugin2->IsUpdatePlugin()); +} + +TEST_P(PluginInterfaceTest, + isBlueprintPluginShouldOnlyBeTrueForAStarfieldBlueprintPlugin) { + SetBlueprintFlag(dataPath / blankMasterDependentEsp); + + const auto plugin1 = LoadPluginHeader(blankEsp); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsp); + + EXPECT_FALSE(plugin1->IsBlueprintPlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, plugin2->IsBlueprintPlugin()); +} + +TEST_P(PluginInterfaceTest, loadingAPluginWithMastersShouldReadThemCorrectly) { + const auto plugin = LoadPluginHeader(blankMasterDependentEsp); + + if (GetParam() == GameType::starfield) { + EXPECT_EQ(std::vector({blankFullEsm}), plugin->GetMasters()); + } else { + EXPECT_EQ(std::vector({blankEsm}), plugin->GetMasters()); + } +} + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveThatExactlyMatchesAnEsmFileBasenameShouldReturnTrueForAllGamesExceptMorrowindAndOblivion) { + bool loadsArchive = LoadPluginHeader(blankEsm)->LoadsArchive(); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::tes4 || + GetParam() == GameType::oblivionRemastered) + EXPECT_FALSE(loadsArchive); + else + EXPECT_TRUE(loadsArchive); +} + +#ifdef _WIN32 +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveThatExactlyMatchesANonAsciiEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowindAndStarfield) { + bool loadsArchive = LoadPluginHeader(nonAsciiEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw || + GetParam() == GameType::starfield) + EXPECT_FALSE(loadsArchive); + else + EXPECT_TRUE(loadsArchive); +} +#endif + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveThatExactlyMatchesAnEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowind) { + bool loadsArchive = LoadPluginHeader(blankEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes3 || GetParam() == GameType::openmw) + EXPECT_FALSE(loadsArchive); + else + EXPECT_TRUE(loadsArchive); +} + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEsmFileBasenameShouldReturnTrueForOnlyTheFalloutGames) { + bool loadsArchive = LoadPluginHeader(blankDifferentEsm)->LoadsArchive(); + + if (GetParam() == GameType::fo3 || GetParam() == GameType::fonv || + GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr) { + EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } +} + +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheEspFileBasenameShouldReturnTrueForOnlyOblivionAndTheFalloutGames) { + bool loadsArchive = LoadPluginHeader(blankDifferentEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes4 || GetParam() == GameType::fo3 || + GetParam() == GameType::fonv || GetParam() == GameType::fo4 || + GetParam() == GameType::fo4vr || + GetParam() == GameType::oblivionRemastered) { + EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } +} + +#ifdef _WIN32 +TEST_P( + PluginInterfaceTest, + loadsArchiveForAnArchiveWithAFilenameWhichStartsWithTheNonAsciiEspFileBasenameShouldReturnTrueForOnlyOblivionAndTheFalloutGames) { + bool loadsArchive = LoadPluginHeader(otherNonAsciiEsp)->LoadsArchive(); + + if (GetParam() == GameType::tes4 || GetParam() == GameType::fo3 || + GetParam() == GameType::fonv || GetParam() == GameType::fo4 || + GetParam() == GameType::fo4vr || + GetParam() == GameType::oblivionRemastered) { + EXPECT_TRUE(loadsArchive); + } else { + EXPECT_FALSE(loadsArchive); + } +} +#endif + +TEST_P(PluginInterfaceTest, + loadsArchiveShouldReturnFalseForAPluginThatDoesNotLoadAnArchive) { + const auto pluginName = GetParam() == GameType::starfield + ? blankDifferentEsp + : blankDifferentMasterDependentEsp; + bool loadsArchive = LoadPluginHeader(pluginName)->LoadsArchive(); + + EXPECT_FALSE(loadsArchive); +} + +TEST_P( + PluginInterfaceTest, + isValidAsLightPluginShouldReturnTrueOnlyForASkyrimSEOrFallout4PluginWithNewFormIdsBetween0x800And0xFFFInclusive) { + bool valid = LoadPlugin(blankEsm)->IsValidAsLightPlugin(); + if (GetParam() == GameType::fo4 || GetParam() == GameType::fo4vr || + GetParam() == GameType::tes5se || GetParam() == GameType::tes5vr || + GetParam() == GameType::starfield) { + EXPECT_TRUE(valid); + } else { + EXPECT_FALSE(valid); + } +} + +TEST_P( + PluginInterfaceTest, + isValidAsMediumPluginShouldReturnTrueOnlyForAStarfieldPluginWithNewFormIdsBetween0And0xFFFFInclusive) { + bool valid = LoadPlugin(blankEsm)->IsValidAsMediumPlugin(); + if (GetParam() == GameType::starfield) { + EXPECT_TRUE(valid); + } else { + EXPECT_FALSE(valid); + } +} + +TEST_P( + PluginInterfaceTest, + IsValidAsUpdatePluginShouldOnlyReturnTrueForAStarfieldPluginWithNoNewRecords) { + const auto sourcePluginName = + GetParam() == GameType::starfield ? blankFullEsm : blankEsp; + const auto updatePluginName = GetParam() == GameType::starfield + ? blankMasterDependentEsp + : blankDifferentPluginDependentEsp; + + std::vector> plugins; + plugins.push_back(LoadPlugin(sourcePluginName)); + plugins.push_back(LoadPlugin(updatePluginName)); + + EXPECT_FALSE(plugins[0]->IsValidAsUpdatePlugin()); + EXPECT_EQ(GetParam() == GameType::starfield, + plugins[1]->IsValidAsUpdatePlugin()); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnFalseIfTheArgumentIsNotAPluginObject) { + const auto plugin1 = LoadPlugin(blankEsm); + TestPlugin plugin2; + + EXPECT_FALSE(plugin1->DoRecordsOverlap(plugin2)); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnFalseForTwoPluginsWithOnlyHeadersLoaded) { + const auto plugin1 = LoadPluginHeader(blankEsm); + const auto plugin2 = LoadPluginHeader(blankMasterDependentEsm); + + EXPECT_FALSE(plugin1->DoRecordsOverlap(*plugin2)); + EXPECT_FALSE(plugin2->DoRecordsOverlap(*plugin1)); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnFalseIfThePluginsHaveUnrelatedRecords) { + const auto plugin1 = LoadPlugin(blankEsm); + const auto plugin2 = LoadPlugin(blankEsp); + + EXPECT_FALSE(plugin1->DoRecordsOverlap(*plugin2)); + EXPECT_FALSE(plugin2->DoRecordsOverlap(*plugin1)); +} + +TEST_P(PluginInterfaceTest, + doRecordsOverlapShouldReturnTrueIfOnePluginOverridesTheOthersRecords) { + const auto plugin1Name = + GetParam() == GameType::starfield ? blankFullEsm : blankEsm; + + const auto plugin1 = LoadPlugin(plugin1Name); + const auto plugin2 = LoadPlugin(blankMasterDependentEsm); + + EXPECT_TRUE(plugin1->DoRecordsOverlap(*plugin2)); + EXPECT_TRUE(plugin2->DoRecordsOverlap(*plugin1)); +} + +} + +#endif diff --git a/cpp/src/tests/api/internals/main.cpp b/cpp/src/tests/api/internals/main.cpp index 1b0774e6..d070639d 100644 --- a/cpp/src/tests/api/internals/main.cpp +++ b/cpp/src/tests/api/internals/main.cpp @@ -4,15 +4,6 @@ #include "libloot-cpp/src/lib.rs.h" #include "rust/cxx.h" -#include "tests/api/internals/metadata/conditional_metadata_test.h" -#include "tests/api/internals/metadata/file_test.h" -#include "tests/api/internals/metadata/group_test.h" -#include "tests/api/internals/metadata/location_test.h" -#include "tests/api/internals/metadata/message_content_test.h" -#include "tests/api/internals/metadata/message_test.h" -#include "tests/api/internals/metadata/plugin_cleaning_data_test.h" -#include "tests/api/internals/metadata/plugin_metadata_test.h" -#include "tests/api/internals/metadata/tag_test.h" namespace rust { template @@ -28,7 +19,7 @@ namespace loot::rust { TEST(libloot_version, shouldReturnExpectedValue) { auto version = libloot_version(); - EXPECT_EQ(version, "0.26.0"); + EXPECT_EQ(version, "0.26.1"); } TEST(libloot_revision, shouldReturnExpectedValue) { diff --git a/cpp/src/tests/common_game_test_fixture.h b/cpp/src/tests/common_game_test_fixture.h index 316a5068..dc47b44f 100644 --- a/cpp/src/tests/common_game_test_fixture.h +++ b/cpp/src/tests/common_game_test_fixture.h @@ -39,7 +39,7 @@ along with LOOT. If not, see namespace loot { namespace test { -static const std::array ALL_GAME_TYPES = { +static const std::array ALL_GAME_TYPES = { GameType::tes3, GameType::tes4, GameType::tes5, @@ -51,6 +51,7 @@ static const std::array ALL_GAME_TYPES = { GameType::fo4vr, GameType::starfield, GameType::openmw, + GameType::oblivionRemastered, }; class CommonGameTestFixture : public ::testing::Test { @@ -236,8 +237,11 @@ protected: } } for (const auto& plugin : loadOrder) actual.push_back(plugin.second); - } else if (gameType_ == GameType::tes5) { - std::ifstream in(localPath / "loadorder.txt"); + } else if (gameType_ == GameType::tes5 || + gameType_ == GameType::oblivionRemastered) { + const auto& parentPath = + gameType_ == GameType::oblivionRemastered ? dataPath : localPath; + std::ifstream in(parentPath / "loadorder.txt"); while (in) { std::string line; std::getline(in, line); @@ -380,6 +384,17 @@ protected: WriteFile(path, bytes); } + static bool endsWith(const std::string& str, const std::string& suffix) { + if (str.length() < suffix.length()) { + return false; + } + + auto view = std::string_view(str); + view.remove_prefix(str.length() - suffix.length()); + + return view == suffix; + } + private: GameType gameType_; const std::filesystem::path rootTestPath; @@ -418,7 +433,8 @@ private: std::string getMasterFile() const { if (gameType_ == GameType::tes3 || gameType_ == GameType::openmw) return "Morrowind.esm"; - else if (gameType_ == GameType::tes4) + else if (gameType_ == GameType::tes4 || + gameType_ == GameType::oblivionRemastered) return "Oblivion.esm"; else if (gameType_ == GameType::tes5 || gameType_ == GameType::tes5se || gameType_ == GameType::tes5vr) @@ -440,6 +456,8 @@ private: return "resources/vfs"; } else if (gameType_ == GameType::tes3) { return "Data Files"; + } else if (gameType_ == GameType::oblivionRemastered) { + return "OblivionRemastered/Content/Dev/ObvData/Data"; } else { return "Data"; } @@ -451,6 +469,7 @@ private: case GameType::openmw: return 0x790DC6FB; case GameType::tes4: + case GameType::oblivionRemastered: return 0x374E2A6F; case GameType::starfield: return 0xDE586309; @@ -477,7 +496,9 @@ private: } } } else { - std::ofstream out(localPath / "Plugins.txt"); + const auto& parentPath = + gameType_ == GameType::oblivionRemastered ? dataPath : localPath; + std::ofstream out(parentPath / "Plugins.txt"); for (const auto& plugin : loadOrder) { if (supportsLightPlugins(gameType_)) { if (plugin.second) @@ -504,8 +525,11 @@ private: } modificationTime += std::chrono::seconds(60); } - } else if (gameType_ == GameType::tes5) { - std::ofstream out(localPath / "loadorder.txt"); + } else if (gameType_ == GameType::tes5 || + gameType_ == GameType::oblivionRemastered) { + const auto& parentPath = + gameType_ == GameType::oblivionRemastered ? dataPath : localPath; + std::ofstream out(parentPath / "loadorder.txt"); for (const auto& plugin : loadOrder) out << plugin.first << std::endl; } } @@ -514,17 +538,6 @@ private: return gameType == GameType::tes3 || gameType == GameType::tes4 || gameType == GameType::fo3 || gameType == GameType::fonv; } - - static bool endsWith(const std::string& str, const std::string& suffix) { - if (str.length() < suffix.length()) { - return false; - } - - auto view = std::string_view(str); - view.remove_prefix(str.length() - suffix.length()); - - return view == suffix; - } }; } } diff --git a/cpp/src/tests/test_helpers.h b/cpp/src/tests/test_helpers.h index a40d23c0..9b8d5a68 100644 --- a/cpp/src/tests/test_helpers.h +++ b/cpp/src/tests/test_helpers.h @@ -42,7 +42,8 @@ std::filesystem::path getSourcePluginsPath(GameType gameType) { using std::filesystem::absolute; if (gameType == GameType::tes3 || gameType == GameType::openmw) { return absolute("./testing-plugins/Morrowind/Data Files"); - } else if (gameType == GameType::tes4) { + } else if (gameType == GameType::tes4 || + gameType == GameType::oblivionRemastered) { return absolute("./testing-plugins/Oblivion/Data"); } else if (gameType == GameType::starfield) { return absolute("./testing-plugins/Starfield/Data"); diff --git a/ffi-errors/Cargo.toml b/ffi-errors/Cargo.toml index 600bcc53..90a01d20 100644 --- a/ffi-errors/Cargo.toml +++ b/ffi-errors/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot-ffi-errors" -version = "0.26.0" +version = "0.26.1" edition = "2024" [dependencies] diff --git a/nodejs/Cargo.toml b/nodejs/Cargo.toml index 904239a1..59f0cd7b 100644 --- a/nodejs/Cargo.toml +++ b/nodejs/Cargo.toml @@ -1,7 +1,7 @@ [package] edition = "2021" name = "libloot-nodejs" -version = "0.26.0" +version = "0.26.1" [lib] crate-type = ["cdylib"] diff --git a/nodejs/__test__/index.spec.mjs b/nodejs/__test__/index.spec.mjs index 79b5d2c3..39100e59 100644 --- a/nodejs/__test__/index.spec.mjs +++ b/nodejs/__test__/index.spec.mjs @@ -3,7 +3,7 @@ import test from 'ava' import { liblootVersion, isCompatible, Group } from '../index.js' test('liblootVersion', t => { - t.is(liblootVersion(), "0.26.0") + t.is(liblootVersion(), "0.26.1") }); test('isCompatible', t => { diff --git a/nodejs/npm/linux-x64-gnu/package.json b/nodejs/npm/linux-x64-gnu/package.json index 870e000e..e07ad6b4 100644 --- a/nodejs/npm/linux-x64-gnu/package.json +++ b/nodejs/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs-linux-x64-gnu", - "version": "0.26.0", + "version": "0.26.1", "os": [ "linux" ], diff --git a/nodejs/npm/win32-x64-msvc/package.json b/nodejs/npm/win32-x64-msvc/package.json index 8d98ecfe..c68ab72c 100644 --- a/nodejs/npm/win32-x64-msvc/package.json +++ b/nodejs/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs-win32-x64-msvc", - "version": "0.26.0", + "version": "0.26.1", "os": [ "win32" ], diff --git a/nodejs/package.json b/nodejs/package.json index 5f534b89..44ad1324 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "libloot-nodejs", - "version": "0.26.0", + "version": "0.26.1", "main": "index.js", "types": "index.d.ts", "napi": { diff --git a/nodejs/src/game.rs b/nodejs/src/game.rs index 0932010f..8fb45d3a 100644 --- a/nodejs/src/game.rs +++ b/nodejs/src/game.rs @@ -19,6 +19,7 @@ pub enum GameType { Morrowind, Starfield, OpenMW, + OblivionRemastered, } impl TryFrom for GameType { @@ -37,6 +38,7 @@ impl TryFrom for GameType { libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } @@ -56,6 +58,7 @@ impl From for libloot::GameType { GameType::Morrowind => libloot::GameType::Morrowind, GameType::Starfield => libloot::GameType::Starfield, GameType::OpenMW => libloot::GameType::OpenMW, + GameType::OblivionRemastered => libloot::GameType::OblivionRemastered, } } } diff --git a/python/Cargo.toml b/python/Cargo.toml index 529f1d93..40307e5a 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libloot_python" -version = "0.26.0" +version = "0.26.1" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/python/src/game.rs b/python/src/game.rs index 84b7699d..2eabb52d 100644 --- a/python/src/game.rs +++ b/python/src/game.rs @@ -19,6 +19,7 @@ pub enum GameType { Morrowind, Starfield, OpenMW, + OblivionRemastered, } impl TryFrom for GameType { @@ -37,6 +38,7 @@ impl TryFrom for GameType { libloot::GameType::Morrowind => Ok(GameType::Morrowind), libloot::GameType::Starfield => Ok(GameType::Starfield), libloot::GameType::OpenMW => Ok(GameType::OpenMW), + libloot::GameType::OblivionRemastered => Ok(GameType::OblivionRemastered), _ => Err(UnsupportedEnumValueError), } } @@ -58,6 +60,7 @@ impl TryFrom for libloot::GameType { GameType::Morrowind => Ok(libloot::GameType::Morrowind), GameType::Starfield => Ok(libloot::GameType::Starfield), GameType::OpenMW => Ok(libloot::GameType::OpenMW), + GameType::OblivionRemastered => Ok(libloot::GameType::OblivionRemastered), } } } diff --git a/src/archive/find.rs b/src/archive/find.rs index 796708f7..8b9a4b81 100644 --- a/src/archive/find.rs +++ b/src/archive/find.rs @@ -27,7 +27,7 @@ pub fn find_associated_archives( // Oblivion .esp files can load archives which begin with the plugin // basename. - GameType::Oblivion => { + GameType::Oblivion | GameType::OblivionRemastered => { if has_ascii_extension(plugin_path, "esp") { find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) } else { @@ -308,7 +308,10 @@ mod tests { if matches!( game_type, - GameType::Morrowind | GameType::OpenMW | GameType::Oblivion + GameType::Morrowind + | GameType::OpenMW + | GameType::Oblivion + | GameType::OblivionRemastered ) { assert!(archives.is_empty()); } else { @@ -397,6 +400,7 @@ mod tests { if matches!( game_type, GameType::Oblivion + | GameType::OblivionRemastered | GameType::Fallout3 | GameType::FalloutNV | GameType::Fallout4 diff --git a/src/game.rs b/src/game.rs index c70d5a3e..389d8f9c 100644 --- a/src/game.rs +++ b/src/game.rs @@ -58,6 +58,8 @@ pub enum GameType { Starfield, /// OpenMW OpenMW, + /// The Elder Scrolls IV: Oblivion Remastered + OblivionRemastered, } impl Display for GameType { @@ -74,6 +76,7 @@ impl Display for GameType { GameType::Morrowind => write!(f, "The Elder Scrolls III: Morrowind"), GameType::Starfield => write!(f, "Starfield"), GameType::OpenMW => write!(f, "OpenMW"), + GameType::OblivionRemastered => write!(f, "The Elder Scrolls IV: Oblivion Remastered"), } } } @@ -92,6 +95,7 @@ impl From for loadorder::GameId { GameType::Morrowind => loadorder::GameId::Morrowind, GameType::Starfield => loadorder::GameId::Starfield, GameType::OpenMW => loadorder::GameId::OpenMW, + GameType::OblivionRemastered => loadorder::GameId::OblivionRemastered, } } } @@ -99,7 +103,9 @@ impl From for loadorder::GameId { impl From for loot_condition_interpreter::GameType { fn from(value: GameType) -> Self { match value { - GameType::Oblivion => loot_condition_interpreter::GameType::Oblivion, + GameType::Oblivion | GameType::OblivionRemastered => { + loot_condition_interpreter::GameType::Oblivion + } GameType::Skyrim => loot_condition_interpreter::GameType::Skyrim, GameType::Fallout3 => loot_condition_interpreter::GameType::Fallout3, GameType::FalloutNV => loot_condition_interpreter::GameType::FalloutNV, @@ -117,7 +123,7 @@ impl From for loot_condition_interpreter::GameType { impl From for esplugin::GameId { fn from(value: GameType) -> Self { match value { - GameType::Oblivion => esplugin::GameId::Oblivion, + GameType::Oblivion | GameType::OblivionRemastered => esplugin::GameId::Oblivion, GameType::Skyrim => esplugin::GameId::Skyrim, GameType::Fallout3 => esplugin::GameId::Fallout3, GameType::FalloutNV => esplugin::GameId::FalloutNV, @@ -555,6 +561,9 @@ fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { match game_type { GameType::Morrowind => game_path.join("Data Files"), GameType::OpenMW => game_path.join("resources/vfs"), + GameType::OblivionRemastered => { + game_path.join("OblivionRemastered/Content/Dev/ObvData/Data") + } _ => game_path.join("Data"), } } @@ -916,7 +925,10 @@ mod tests { fn should_succeed_for_morrowind_if_given_valid_game_path(game_type: GameType) { let fixture = Fixture::new(game_type); - if matches!(game_type, GameType::Morrowind | GameType::OpenMW) { + if matches!( + game_type, + GameType::Morrowind | GameType::OpenMW | GameType::OblivionRemastered + ) { assert!(Game::new(fixture.game_type, &fixture.game_path).is_ok()); } else { assert!(Game::new(fixture.game_type, &fixture.game_path).is_err()); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 3cff3a2e..f02402fc 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -170,7 +170,10 @@ impl Plugin { /// sometimes referred to as *master files* or simply *masters*, while the /// other meaning is always referenced in relation to another plugin. pub fn is_master(&self) -> bool { - if self.game_type == GameType::OpenMW { + if matches!( + self.game_type, + GameType::OpenMW | GameType::OblivionRemastered + ) { false } else { self.plugin @@ -535,7 +538,11 @@ mod tests { assert_eq!(plugin_name, plugin.name()); assert_eq!(expected_masters, plugin.masters().unwrap()); - assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + if matches!(game_type, GameType::OpenMW | GameType::OblivionRemastered) { + assert!(!plugin.is_master()); + } else { + assert!(plugin.is_master()); + } assert!(!plugin.is_empty()); assert!(plugin.version().is_none()); @@ -544,7 +551,9 @@ mod tests { GameType::Morrowind | GameType::OpenMW => { assert_eq!(1.2, plugin.header_version().unwrap()); } - GameType::Oblivion => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Oblivion | GameType::OblivionRemastered => { + assert_eq!(0.8, plugin.header_version().unwrap()); + } GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), _ => assert_eq!(0.94, plugin.header_version().unwrap()), } @@ -606,7 +615,11 @@ mod tests { assert_eq!(plugin_name, plugin.name()); assert_eq!(expected_masters, plugin.masters().unwrap()); - assert_eq!(game_type != GameType::OpenMW, plugin.is_master()); + if matches!(game_type, GameType::OpenMW | GameType::OblivionRemastered) { + assert!(!plugin.is_master()); + } else { + assert!(plugin.is_master()); + } assert!(!plugin.is_empty()); assert!(plugin.version().is_none()); @@ -615,7 +628,9 @@ mod tests { GameType::Morrowind | GameType::OpenMW => { assert_eq!(1.2, plugin.header_version().unwrap()); } - GameType::Oblivion => assert_eq!(0.8, plugin.header_version().unwrap()), + GameType::Oblivion | GameType::OblivionRemastered => { + assert_eq!(0.8, plugin.header_version().unwrap()); + } GameType::Starfield => assert_eq!(0.96, plugin.header_version().unwrap()), _ => assert_eq!(0.94, plugin.header_version().unwrap()), } @@ -623,7 +638,7 @@ mod tests { let expected_crc = match game_type { GameType::Morrowind | GameType::OpenMW => 3_317_676_987, GameType::Starfield => 1_422_425_298, - GameType::Oblivion => 3_759_349_588, + GameType::Oblivion | GameType::OblivionRemastered => 3_759_349_588, _ => 3_000_242_590, }; diff --git a/src/tests.rs b/src/tests.rs index 4cf5fd38..96ecef30 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -31,7 +31,9 @@ pub fn source_plugins_path(game_type: GameType) -> PathBuf { GameType::Morrowind | GameType::OpenMW => { absolute("./testing-plugins/Morrowind/Data Files") } - GameType::Oblivion => absolute("./testing-plugins/Oblivion/Data"), + GameType::Oblivion | GameType::OblivionRemastered => { + absolute("./testing-plugins/Oblivion/Data") + } GameType::Starfield => absolute("./testing-plugins/Starfield/Data"), GameType::Fallout3 | GameType::FalloutNV | GameType::Skyrim => { absolute("./testing-plugins/Skyrim/Data") @@ -44,7 +46,7 @@ pub fn source_plugins_path(game_type: GameType) -> PathBuf { fn master_file(game_type: GameType) -> &'static str { match game_type { GameType::Morrowind | GameType::OpenMW => "Morrowind.esm", - GameType::Oblivion => "Oblivion.esm", + GameType::Oblivion | GameType::OblivionRemastered => "Oblivion.esm", GameType::Skyrim | GameType::SkyrimSE | GameType::SkyrimVR => "Skyrim.esm", GameType::Fallout3 => "Fallout3.esm", GameType::FalloutNV => "FalloutNV.esm", @@ -155,7 +157,7 @@ fn set_load_order( mod_time += Duration::from_secs(60); } - } else if game_type == GameType::Skyrim { + } else if matches!(game_type, GameType::Skyrim | GameType::OblivionRemastered) { let mut file = File::create(local_path.join("loadorder.txt")).unwrap(); for (plugin, _) in load_order { writeln!(file, "{plugin}").unwrap(); @@ -167,6 +169,9 @@ fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { match game_type { GameType::OpenMW => game_path.join("resources/vfs"), GameType::Morrowind => game_path.join("Data Files"), + GameType::OblivionRemastered => { + game_path.join("OblivionRemastered/Content/Dev/ObvData/Data") + } _ => game_path.join("Data"), } } @@ -315,7 +320,7 @@ impl Fixture { } #[test_parameter] -pub const ALL_GAME_TYPES: [GameType; 11] = [ +pub const ALL_GAME_TYPES: [GameType; 12] = [ GameType::Oblivion, GameType::Skyrim, GameType::Fallout3, @@ -327,6 +332,7 @@ pub const ALL_GAME_TYPES: [GameType; 11] = [ GameType::Morrowind, GameType::Starfield, GameType::OpenMW, + GameType::OblivionRemastered, ]; mod unicase {