From a3a90062e177568d8b7d7920f8f883f00024bffd Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Fri, 30 Aug 2019 21:26:20 +0100 Subject: [PATCH] Count override records using masters for TES3 plugins If a plugin's masters are all present, this makes the sorting logic for Morrowind match the other supported games. If a plugin's masters are missing, use the plugin's total record count as the override record count. Plugins with missing masters cannot be loaded by the game, reducing the impact (if any) of the probably-inflated record counts. It's better for LOOT to be able to sort a slightly wonky load order if there are missing masters than for it to fail completely, as such plugins may be present for development and testing reasons. --- CMakeLists.txt | 3 +- docs/api/sorting.rst | 8 +- src/api/plugin.cpp | 79 ++++++-- src/api/plugin.h | 3 + src/api/sorting/plugin_sorter.cpp | 11 +- src/api/sorting/plugin_sorting_data.cpp | 56 +++++- src/api/sorting/plugin_sorting_data.h | 5 +- src/tests/api/internals/main.cpp | 1 + src/tests/api/internals/plugin_test.h | 89 ++++++++- .../internals/sorting/plugin_sorter_test.h | 44 +---- .../sorting/plugin_sorting_data_test.h | 175 ++++++++++++++++++ 11 files changed, 393 insertions(+), 81 deletions(-) create mode 100644 src/tests/api/internals/sorting/plugin_sorting_data_test.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b947e718..68a8587b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,7 +82,7 @@ set (GTEST_LIBRARIES "${BINARY_DIR}/googlemock/gtest/${CMAKE_CFG_INTDIR}/${CMAKE ExternalProject_Add(esplugin PREFIX "external" - URL "https://github.com/Ortham/esplugin/archive/2.1.2.tar.gz" + URL "https://github.com/Ortham/esplugin/archive/3.2.0.tar.gz" CONFIGURE_COMMAND "" BUILD_IN_SOURCE 1 BUILD_COMMAND cargo build --release --manifest-path ffi/Cargo.toml --target ${RUST_TARGET} && @@ -296,6 +296,7 @@ set (LOOT_TESTS_HEADERS "${CMAKE_SOURCE_DIR}/src/tests/api/internals/game/game_t "${CMAKE_SOURCE_DIR}/src/tests/api/internals/plugin_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/internals/sorting/group_sort_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/internals/sorting/plugin_sorter_test.h" + "${CMAKE_SOURCE_DIR}/src/tests/api/internals/sorting/plugin_sorting_data_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/internals/masterlist_test.h" "${CMAKE_SOURCE_DIR}/src/tests/api/internals/metadata_list_test.h" "${CMAKE_SOURCE_DIR}/src/tests/common_game_test_fixture.h" diff --git a/docs/api/sorting.rst b/docs/api/sorting.rst index 4b812d57..1c612433 100644 --- a/docs/api/sorting.rst +++ b/docs/api/sorting.rst @@ -58,15 +58,17 @@ same record, i.e. if they both edit the same record or if one edits a record the other plugin adds. For each plugin, skip it if it overrides no records, otherwise iterate over all -other plugins. Sorting currently skips adding overlap edges for Morrowind -plugins, because LOOT is unable to distinguish between new and overridden -records in Morrowind plugins, and considers all plugins to override no records. +other plugins. * If the plugin and other plugin override the same number of records, or do not overlap, skip the other plugin. * Otherwise, add an edge from the plugin which overrides more records to the plugin that overrides fewer records, unless that edge would cause a cycle. +For Morrowind, identifying which records override others requires all of a +plugin's masters to be installed, so if a plugin has missing masters, its total +record count is used in place of its override record count. + Finally, tie-break edges are added to ensure that sorting is consistent. For each plugin, iterate over all other plugins and add an edge between each pair of plugins in the direction given by the tie-break comparison function, unless that diff --git a/src/api/plugin.cpp b/src/api/plugin.cpp index d6522b32..a205cd98 100644 --- a/src/api/plugin.cpp +++ b/src/api/plugin.cpp @@ -60,7 +60,9 @@ Plugin::Plugin(const GameType gameType, auto ret = esp_plugin_is_empty(esPlugin.get(), &isEmpty_); if (ret != ESP_OK) { - throw FileAccessError("Error checking if \"" + name_ + "\" is empty. esplugin error code: " + std::to_string(ret)); + throw FileAccessError( + "Error checking if \"" + name_ + + "\" is empty. esplugin error code: " + std::to_string(ret)); } if (!headerOnly) { @@ -69,7 +71,9 @@ Plugin::Plugin(const GameType gameType, ret = esp_plugin_count_override_records(esPlugin.get(), &numOverrideRecords_); if (ret != ESP_OK) { - throw FileAccessError("Error counting override records in \"" + name_ + "\". esplugin error code: " + std::to_string(ret)); + throw FileAccessError( + "Error counting override records in \"" + name_ + + "\". esplugin error code: " + std::to_string(ret)); } } @@ -80,7 +84,8 @@ Plugin::Plugin(const GameType gameType, logger->error( "Cannot read plugin file \"{}\". Details: {}", name_, e.what()); } - throw FileAccessError("Cannot read \"" + name_ + "\". Details: " + e.what()); + throw FileAccessError("Cannot read \"" + name_ + + "\". Details: " + e.what()); } } @@ -91,7 +96,7 @@ float Plugin::GetHeaderVersion() const { auto ret = esp_plugin_header_version(esPlugin.get(), &version); if (ret != ESP_OK) { throw FileAccessError(name_ + - " : esplugin error code: " + std::to_string(ret)); + " : esplugin error code: " + std::to_string(ret)); } return version; @@ -142,13 +147,12 @@ bool Plugin::IsLightMaster() const { return isLightMaster; } -bool Plugin::IsValidAsLightMaster() const -{ +bool Plugin::IsValidAsLightMaster() const { bool isValid; auto ret = esp_plugin_is_valid_as_light_master(esPlugin.get(), &isValid); if (ret != ESP_OK) { throw FileAccessError(name_ + - " : esplugin error code: " + std::to_string(ret)); + " : esplugin error code: " + std::to_string(ret)); } return isValid; @@ -183,25 +187,58 @@ bool Plugin::DoFormIDsOverlap(const PluginInterface& plugin) const { return false; } +size_t Plugin::GetOverlapSize( + const std::vector> plugins) const { + if (plugins.empty()) { + return 0; + } + + std::vector<::Plugin*> esPlugins; + for (const auto& plugin : plugins) { + esPlugins.push_back(plugin->esPlugin.get()); + } + + size_t overlapSize; + auto ret = esp_plugin_records_overlap_size( + esPlugin.get(), &esPlugins[0], esPlugins.size(), &overlapSize); + if (ret != ESP_OK) { + throw FileAccessError("Error getting overlap size for \"" + name_ + + "\". esplugin error code: " + std::to_string(ret)); + } + + return overlapSize; +} + size_t Plugin::NumOverrideFormIDs() const { return numOverrideRecords_; } +uint32_t Plugin::GetRecordAndGroupCount() const { + uint32_t recordAndGroupCount = 0; + auto ret = + esp_plugin_record_and_group_count(esPlugin.get(), &recordAndGroupCount); + if (ret != ESP_OK) { + throw FileAccessError("Error getting record and group count for \"" + + name_ + + "\". esplugin error code: " + std::to_string(ret)); + } + + return recordAndGroupCount; +} + bool Plugin::IsValid(const GameType gameType, const std::filesystem::path& pluginPath) { // Check that the file has a valid extension. if (hasPluginFileExtension(pluginPath.filename().u8string(), gameType)) { bool isValid; int returnCode = esp_plugin_is_valid(GetEspluginGameId(gameType), - pluginPath.u8string().c_str(), - true, - &isValid); + pluginPath.u8string().c_str(), + true, + &isValid); if (returnCode != ESP_OK || !isValid) { // Try adding .ghost extension. auto ghostedFilename = pluginPath.u8string() + ".ghost"; - returnCode = esp_plugin_is_valid(GetEspluginGameId(gameType), - ghostedFilename.c_str(), - true, - &isValid); + returnCode = esp_plugin_is_valid( + GetEspluginGameId(gameType), ghostedFilename.c_str(), true, &isValid); } if (returnCode == ESP_OK && isValid) { @@ -212,7 +249,7 @@ bool Plugin::IsValid(const GameType gameType, auto logger = getLogger(); if (logger) { logger->info("The file \"{}\" is not a valid plugin.", - pluginPath.filename().u8string()); + pluginPath.filename().u8string()); } return false; @@ -274,11 +311,13 @@ std::string GetArchiveFileExtension(const GameType gameType) { return ".bsa"; } -std::filesystem::path replaceExtension(std::filesystem::path path, const std::string& newExtension) { +std::filesystem::path replaceExtension(std::filesystem::path path, + const std::string& newExtension) { return path.replace_extension(std::filesystem::u8path(newExtension)); } -bool equivalent(const std::filesystem::path& path1, const std::filesystem::path& path2) { +bool equivalent(const std::filesystem::path& path1, + const std::filesystem::path& path2) { // If the paths are identical, they've got to be equivalent, // it doesn't matter if the paths exist or not. if (path1 == path2) { @@ -314,7 +353,8 @@ bool Plugin::LoadsArchive(const GameType gameType, if (gameType == GameType::tes5) { // Skyrim plugins only load BSAs that exactly match their basename. - return std::filesystem::exists(replaceExtension(pluginPath, archiveExtension)); + return std::filesystem::exists( + replaceExtension(pluginPath, archiveExtension)); } else if (gameType != GameType::tes4 || boost::iends_with(pluginPath.filename().u8string(), ".esp")) { // Oblivion .esp files and FO3, FNV, FO4 plugins can load archives which @@ -331,8 +371,7 @@ bool Plugin::LoadsArchive(const GameType gameType, auto bsaPluginFilename = archivePath.filename().native().substr(0, basenameLength) + pluginExtension; - auto bsaPluginPath = - pluginPath.parent_path() / bsaPluginFilename; + auto bsaPluginPath = pluginPath.parent_path() / bsaPluginFilename; if (loot::equivalent(pluginPath, bsaPluginPath)) { return true; } diff --git a/src/api/plugin.h b/src/api/plugin.h index 2dfe9137..f08923ab 100644 --- a/src/api/plugin.h +++ b/src/api/plugin.h @@ -61,9 +61,12 @@ public: bool IsEmpty() const; bool LoadsArchive() const; bool DoFormIDsOverlap(const PluginInterface& plugin) const; + size_t GetOverlapSize( + const std::vector> plugins) const; // Load ordering functions. size_t NumOverrideFormIDs() const; + uint32_t GetRecordAndGroupCount() const; // Validity checks. static bool IsValid(const GameType gameType, diff --git a/src/api/sorting/plugin_sorter.cpp b/src/api/sorting/plugin_sorter.cpp index fa31b517..3df2bdb0 100644 --- a/src/api/sorting/plugin_sorter.cpp +++ b/src/api/sorting/plugin_sorter.cpp @@ -173,7 +173,8 @@ void PluginSorter::AddPluginVertices(Game& game) { auto loadOrder = game.GetLoadOrder(); - for (const auto& plugin : game.GetCache()->GetPlugins()) { + auto loadedPlugins = game.GetCache()->GetPlugins(); + for (const auto& plugin : loadedPlugins) { auto masterlistMetadata = game.GetDatabase() ->GetPluginMetadata(plugin->GetName(), false, true) @@ -182,8 +183,12 @@ void PluginSorter::AddPluginVertices(Game& game) { ->GetPluginUserMetadata(plugin->GetName(), true) .value_or(PluginMetadata(plugin->GetName())); - auto pluginSortingData = - PluginSortingData(*plugin, masterlistMetadata, userMetadata, loadOrder); + auto pluginSortingData = PluginSortingData(*plugin, + masterlistMetadata, + userMetadata, + loadOrder, + game.Type(), + loadedPlugins); auto groupName = pluginSortingData.GetGroup(); auto groupIt = groupPlugins.find(groupName); diff --git a/src/api/sorting/plugin_sorting_data.cpp b/src/api/sorting/plugin_sorting_data.cpp index b3f6a20e..3126fc92 100644 --- a/src/api/sorting/plugin_sorting_data.cpp +++ b/src/api/sorting/plugin_sorting_data.cpp @@ -31,10 +31,31 @@ #include "api/helpers/text.h" namespace loot { -PluginSortingData::PluginSortingData(const Plugin& plugin, - const PluginMetadata& masterlistMetadata, +std::vector> GetPluginsSubset( + const std::set>& plugins, + const std::vector& pluginNames) { + std::vector> pluginsSubset; + + for (const auto& pluginName : pluginNames) { + auto pos = std::find_if(plugins.begin(), plugins.end(), [&](auto plugin) { + return CompareFilenames(plugin->GetName(), pluginName) == 0; + }); + + if (pos != plugins.end()) { + pluginsSubset.push_back(*pos); + } + } + + return pluginsSubset; +} + +PluginSortingData::PluginSortingData( + const Plugin& plugin, + const PluginMetadata& masterlistMetadata, const PluginMetadata& userMetadata, - const std::vector& loadOrder) : + const std::vector& loadOrder, + const GameType gameType, + const std::set>& loadedPlugins) : plugin_(plugin), masterlistLoadAfter_(masterlistMetadata.GetLoadAfterFiles()), userLoadAfter_(userMetadata.GetLoadAfterFiles()), @@ -49,10 +70,35 @@ PluginSortingData::PluginSortingData(const Plugin& plugin, } for (size_t i = 0; i < loadOrder.size(); i++) { - if (CompareFilenames(GetName(), loadOrder[i]) == 0) { + if (CompareFilenames(plugin.GetName(), loadOrder[i]) == 0) { loadOrderIndex_ = i; } } + + if (gameType == GameType::tes3) { + auto masterNames = plugin.GetMasters(); + if (masterNames.empty()) { + numOverrideFormIDs = 0; + } else { + auto masters = GetPluginsSubset(loadedPlugins, masterNames); + if (masters.size() == masterNames.size()) { + numOverrideFormIDs = plugin.GetOverlapSize(masters); + } else { + // Not all masters are loaded, fall back to using the plugin's + // total record count (Morrowind doesn't have groups). This is OK + // because plugins with missing masters can't be loaded by the game, + // so the correctness of their load order positions is less important + // (it may not matter at all, depending on the sophistication/usage of + // merge patches in Morrowind). It's better for LOOT to sort a load + // order with missing masters with potentially poorer results than + // for it to error out, as masters may be missing for a variety of + // development & testing reasons. + numOverrideFormIDs = plugin.GetRecordAndGroupCount(); + } + } + } else { + numOverrideFormIDs = plugin.NumOverrideFormIDs(); + } } std::string PluginSortingData::GetName() const { return plugin_.GetName(); } @@ -69,7 +115,7 @@ std::vector PluginSortingData::GetMasters() const { } size_t PluginSortingData::NumOverrideFormIDs() const { - return plugin_.NumOverrideFormIDs(); + return numOverrideFormIDs; } bool PluginSortingData::DoFormIDsOverlap( diff --git a/src/api/sorting/plugin_sorting_data.h b/src/api/sorting/plugin_sorting_data.h index d2c65341..4aebf427 100644 --- a/src/api/sorting/plugin_sorting_data.h +++ b/src/api/sorting/plugin_sorting_data.h @@ -34,7 +34,9 @@ public: PluginSortingData(const Plugin& plugin, const PluginMetadata& masterlistMetadata, const PluginMetadata& userMetadata, - const std::vector& loadOrder); + const std::vector& loadOrder, + const GameType gameType, + const std::set>& loadedPlugins); std::string GetName() const; bool IsMaster() const; @@ -66,6 +68,7 @@ private: std::set userReq_; std::optional loadOrderIndex_; + size_t numOverrideFormIDs; }; } diff --git a/src/tests/api/internals/main.cpp b/src/tests/api/internals/main.cpp index ccaaf491..0ff1461f 100644 --- a/src/tests/api/internals/main.cpp +++ b/src/tests/api/internals/main.cpp @@ -46,6 +46,7 @@ #include "tests/api/internals/plugin_test.h" #include "tests/api/internals/sorting/group_sort_test.h" #include "tests/api/internals/sorting/plugin_sorter_test.h" +#include "tests/api/internals/sorting/plugin_sorting_data_test.h" TEST(ModuloOperator, shouldConformToTheCpp11Standard) { // C++11 defines the modulo operator more strongly diff --git a/src/tests/api/internals/plugin_test.h b/src/tests/api/internals/plugin_test.h index 1101c89a..54524911 100644 --- a/src/tests/api/internals/plugin_test.h +++ b/src/tests/api/internals/plugin_test.h @@ -304,11 +304,12 @@ TEST_P( TEST_P( PluginTest, loadsArchiveForAnArchiveThatExactlyMatchesANonAsciiEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowind) { - bool loadsArchive = Plugin(game_.Type(), - game_.GetCache(), - game_.DataPath() / std::filesystem::u8path(nonAsciiEsp), - true) - .LoadsArchive(); + bool loadsArchive = + Plugin(game_.Type(), + game_.GetCache(), + game_.DataPath() / std::filesystem::u8path(nonAsciiEsp), + true) + .LoadsArchive(); if (GetParam() == GameType::tes3) EXPECT_FALSE(loadsArchive); @@ -320,7 +321,8 @@ TEST_P( TEST_P( PluginTest, loadsArchiveForAnArchiveThatExactlyMatchesAnEspFileBasenameShouldReturnTrueForAllGamesExceptMorrowind) { - bool loadsArchive = Plugin(game_.Type(), game_.GetCache(), game_.DataPath() / blankEsp, true) + bool loadsArchive = + Plugin(game_.Type(), game_.GetCache(), game_.DataPath() / blankEsp, true) .LoadsArchive(); if (GetParam() == GameType::tes3) @@ -338,7 +340,8 @@ TEST_P( true) .LoadsArchive(); - if (GetParam() == GameType::tes3 || GetParam() == GameType::tes4 || GetParam() == GameType::tes5) + if (GetParam() == GameType::tes3 || GetParam() == GameType::tes4 || + GetParam() == GameType::tes5) EXPECT_FALSE(loadsArchive); else EXPECT_TRUE(loadsArchive); @@ -500,6 +503,78 @@ TEST_P(PluginTest, EXPECT_TRUE(plugin2.DoFormIDsOverlap(plugin1)); } +TEST_P(PluginTest, getOverlapSizeShouldCountEachRecordOnce) { + Plugin plugin1( + game_.Type(), game_.GetCache(), game_.DataPath() / blankEsm, false); + Plugin plugin2(game_.Type(), + game_.GetCache(), + game_.DataPath() / blankMasterDependentEsm, + false); + + std::vector> plugins = { + std::make_shared(plugin2), + std::make_shared(plugin2)}; + + EXPECT_EQ(4, plugin1.GetOverlapSize(plugins)); +} + +TEST_P(PluginTest, getOverlapSizeShouldCheckAgainstAllGivenPlugins) { + Plugin plugin1( + game_.Type(), game_.GetCache(), game_.DataPath() / blankEsm, false); + Plugin plugin2( + game_.Type(), game_.GetCache(), game_.DataPath() / blankEsp, false); + Plugin plugin3(game_.Type(), + game_.GetCache(), + game_.DataPath() / blankMasterDependentEsm, + false); + + std::vector> plugins = { + std::make_shared(plugin2), + std::make_shared(plugin3)}; + + EXPECT_EQ(4, plugin1.GetOverlapSize(plugins)); +} + +TEST_P(PluginTest, + getOverlapSizeShouldReturnZeroForPluginsWithOnlyHeadersLoaded) { + Plugin plugin1( + game_.Type(), game_.GetCache(), game_.DataPath() / blankEsm, true); + Plugin plugin2(game_.Type(), + game_.GetCache(), + game_.DataPath() / blankMasterDependentEsm, + true); + + std::vector> plugins = { + std::make_shared(plugin2)}; + + EXPECT_EQ(0, plugin1.GetOverlapSize(plugins)); +} + +TEST_P(PluginTest, getOverlapSizeShouldReturnZeroForPluginsThatDoNotOverlap) { + Plugin plugin1( + game_.Type(), game_.GetCache(), game_.DataPath() / blankEsm, false); + Plugin plugin2( + game_.Type(), game_.GetCache(), game_.DataPath() / blankEsp, false); + + std::vector> plugins = { + std::make_shared(plugin2)}; + + EXPECT_EQ(0, plugin1.GetOverlapSize(plugins)); +} + +TEST_P(PluginTest, getRecordAndGroupCountShouldReturnTheHeaderFieldValue) { + Plugin plugin( + game_.Type(), game_.GetCache(), game_.DataPath() / blankEsm, true); + + if (GetParam() == GameType::tes3) { + EXPECT_EQ(10, plugin.GetRecordAndGroupCount()); + } else if (GetParam() == GameType::tes4) { + EXPECT_EQ(14, plugin.GetRecordAndGroupCount()); + } else { + EXPECT_EQ(15, plugin.GetRecordAndGroupCount()); + } +} + TEST_P(PluginTest, hasPluginFileExtensionShouldBeTrueIfFileEndsInDotEspOrDotEsm) { EXPECT_TRUE(hasPluginFileExtension("file.esp", GetParam())); diff --git a/src/tests/api/internals/sorting/plugin_sorter_test.h b/src/tests/api/internals/sorting/plugin_sorter_test.h index 85f4c25f..9bb0464c 100644 --- a/src/tests/api/internals/sorting/plugin_sorter_test.h +++ b/src/tests/api/internals/sorting/plugin_sorter_test.h @@ -127,7 +127,9 @@ protected: // but we only have the one so no prefix is necessary. INSTANTIATE_TEST_CASE_P(, PluginSorterTest, - ::testing::Values(GameType::tes4, GameType::fo4)); + ::testing::Values(GameType::tes3, + GameType::tes4, + GameType::fo4)); TEST_P(PluginSorterTest, sortingWithNoLoadedPluginsShouldReturnAnEmptyList) { PluginSorter sorter; @@ -136,46 +138,6 @@ TEST_P(PluginSorterTest, sortingWithNoLoadedPluginsShouldReturnAnEmptyList) { EXPECT_TRUE(sorted.empty()); } -TEST_P(PluginSorterTest, - lightMasterFlaggedEspFilesShouldNotBeTreatedAsMasters) { - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { - ASSERT_NO_THROW( - std::filesystem::copy(dataPath / blankEsl, dataPath / blankEslEsp)); - } - - ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); - - auto esp = PluginSortingData( - *dynamic_cast(game_.GetPlugin(blankEsp).get()), - PluginMetadata(), - PluginMetadata(), - getLoadOrder()); - EXPECT_FALSE(esp.IsMaster()); - - auto master = PluginSortingData( - *dynamic_cast(game_.GetPlugin(blankEsm).get()), - PluginMetadata(), - PluginMetadata(), - getLoadOrder()); - EXPECT_TRUE(master.IsMaster()); - - if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { - auto lightMaster = PluginSortingData( - *dynamic_cast(game_.GetPlugin(blankEsl).get()), - PluginMetadata(), - PluginMetadata(), - getLoadOrder()); - EXPECT_TRUE(lightMaster.IsMaster()); - - auto lightMasterEsp = PluginSortingData( - *dynamic_cast(game_.GetPlugin(blankEslEsp).get()), - PluginMetadata(), - PluginMetadata(), - getLoadOrder()); - EXPECT_FALSE(lightMasterEsp.IsMaster()); - } -} - TEST_P(PluginSorterTest, sortingShouldNotMakeUnnecessaryChangesToAnExistingLoadOrder) { ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); diff --git a/src/tests/api/internals/sorting/plugin_sorting_data_test.h b/src/tests/api/internals/sorting/plugin_sorting_data_test.h new file mode 100644 index 00000000..8185c789 --- /dev/null +++ b/src/tests/api/internals/sorting/plugin_sorting_data_test.h @@ -0,0 +1,175 @@ +/* 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_INTERNALS_SORTING_PLUGIN_SORTING_DATA_TEST +#define LOOT_TESTS_API_INTERNALS_SORTING_PLUGIN_SORTING_DATA_TEST + +#include "api/sorting/plugin_sorting_data.h" + +#include "tests/common_game_test_fixture.h" + +namespace loot { +namespace test { +class PluginSortingDataTest : public CommonGameTestFixture { +protected: + PluginSortingDataTest() : + game_(GetParam(), dataPath.parent_path(), localPath), + blankEslEsp("Blank.esl.esp") {} + + void loadInstalledPlugins(Game &game_, bool headersOnly) { + std::vector plugins({ + masterFile, + blankEsm, + blankDifferentEsm, + blankMasterDependentEsm, + blankDifferentMasterDependentEsm, + blankEsp, + blankDifferentEsp, + blankMasterDependentEsp, + blankDifferentMasterDependentEsp, + blankPluginDependentEsp, + blankDifferentPluginDependentEsp, + }); + + if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + plugins.push_back(blankEsl); + + if (std::filesystem::exists(dataPath / blankEslEsp)) { + plugins.push_back(blankEslEsp); + } + } + + game_.IdentifyMainMasterFile(masterFile); + game_.LoadCurrentLoadOrderState(); + game_.LoadPlugins(plugins, headersOnly); + } + + Game game_; + const std::string blankEslEsp; +}; + +// 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_CASE_P(, + PluginSortingDataTest, + ::testing::Values(GameType::tes3, + GameType::tes4, + GameType::fo4)); + +TEST_P(PluginSortingDataTest, + lightMasterFlaggedEspFilesShouldNotBeTreatedAsMasters) { + if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + ASSERT_NO_THROW( + std::filesystem::copy(dataPath / blankEsl, dataPath / blankEslEsp)); + } + + ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); + + auto esp = PluginSortingData( + *dynamic_cast(game_.GetPlugin(blankEsp).get()), + PluginMetadata(), + PluginMetadata(), + getLoadOrder(), + game_.Type(), + game_.GetCache()->GetPlugins()); + EXPECT_FALSE(esp.IsMaster()); + + auto master = PluginSortingData( + *dynamic_cast(game_.GetPlugin(blankEsm).get()), + PluginMetadata(), + PluginMetadata(), + getLoadOrder(), + game_.Type(), + game_.GetCache()->GetPlugins()); + EXPECT_TRUE(master.IsMaster()); + + if (GetParam() == GameType::fo4 || GetParam() == GameType::tes5se) { + auto lightMaster = PluginSortingData( + *dynamic_cast(game_.GetPlugin(blankEsl).get()), + PluginMetadata(), + PluginMetadata(), + getLoadOrder(), + game_.Type(), + game_.GetCache()->GetPlugins()); + EXPECT_TRUE(lightMaster.IsMaster()); + + auto lightMasterEsp = PluginSortingData( + *dynamic_cast(game_.GetPlugin(blankEslEsp).get()), + PluginMetadata(), + PluginMetadata(), + getLoadOrder(), + game_.Type(), + game_.GetCache()->GetPlugins()); + EXPECT_FALSE(lightMasterEsp.IsMaster()); + } +} + +TEST_P(PluginSortingDataTest, + numOverrideFormIdsShouldEqualSizeOfOverlapWithThePluginsMasters) { + ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); + + auto plugin = + PluginSortingData(*dynamic_cast( + game_.GetPlugin(blankMasterDependentEsm).get()), + PluginMetadata(), + PluginMetadata(), + getLoadOrder(), + game_.Type(), + game_.GetCache()->GetPlugins()); + EXPECT_EQ(4, plugin.NumOverrideFormIDs()); +} + +TEST_P(PluginSortingDataTest, + constructorShouldUseTotalRecordCountAsOverrideFormIdCountForTes3PluginWithAMasterThatIsNotLoaded) { + if (GetParam() != GameType::tes3) { + return; + } + + ASSERT_NO_THROW(loadInstalledPlugins(game_, false)); + + // Pretend that blankEsm isn't loaded. + auto loadedPlugins = game_.GetCache()->GetPlugins(); + for (auto it = loadedPlugins.begin(); it != loadedPlugins.end();) { + if ((*it)->GetName() == blankEsm) { + it = loadedPlugins.erase(it); + } else { + ++it; + } + } + + auto plugin = + PluginSortingData(*dynamic_cast( + game_.GetPlugin(blankMasterDependentEsm).get()), + PluginMetadata(), + PluginMetadata(), + getLoadOrder(), + game_.Type(), + loadedPlugins); + + EXPECT_EQ(10, plugin.NumOverrideFormIDs()); +} +} +} + +#endif