mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-13 00:03:45 +00:00
feat: web dashboard and playground work
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
55
tests/dCommonTests/AccountRulesTests.cpp
Normal file
55
tests/dCommonTests/AccountRulesTests.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "AccountRules.h"
|
||||
#include "Permissions.h"
|
||||
#include "Game.h"
|
||||
#include "dConfig.h"
|
||||
|
||||
using namespace AccountRules;
|
||||
|
||||
TEST(AccountRulesTests, RankAndSelfRules) {
|
||||
// GM 9 acts on anyone, themselves and other GM 9s included
|
||||
EXPECT_EQ(ManageDenial(9, 1, 9, 2, false, false), eManageDenial::NONE);
|
||||
EXPECT_EQ(ManageDenial(9, 1, 9, 1, false, false), eManageDenial::NONE);
|
||||
// Below GM 9: never a higher level, whatever the permissions say
|
||||
EXPECT_EQ(ManageDenial(8, 1, 9, 2, true, true), eManageDenial::HIGHER_RANK);
|
||||
// Their own level only with manage_equal_rank
|
||||
EXPECT_EQ(ManageDenial(4, 1, 4, 2, true, false), eManageDenial::EQUAL_RANK);
|
||||
EXPECT_EQ(ManageDenial(4, 1, 4, 2, false, true), eManageDenial::NONE);
|
||||
// Themselves only with the self permission
|
||||
EXPECT_EQ(ManageDenial(4, 1, 4, 1, false, true), eManageDenial::SELF);
|
||||
EXPECT_EQ(ManageDenial(4, 1, 4, 1, true, false), eManageDenial::NONE);
|
||||
// Lower levels: fine
|
||||
EXPECT_EQ(ManageDenial(2, 1, 0, 2, false, false), eManageDenial::NONE);
|
||||
}
|
||||
|
||||
TEST(AccountRulesTests, MessagesNameThePermission) {
|
||||
EXPECT_NE(DenialMessage(eManageDenial::SELF, eAccountAction::ITEMS).find("self_items"), std::string::npos);
|
||||
EXPECT_NE(DenialMessage(eManageDenial::SELF, eAccountAction::MODERATION).find("self_moderation"), std::string::npos);
|
||||
EXPECT_NE(DenialMessage(eManageDenial::SELF, eAccountAction::TOOLS).find("self_tools"), std::string::npos);
|
||||
EXPECT_NE(DenialMessage(eManageDenial::EQUAL_RANK, eAccountAction::TOOLS).find("manage_equal_rank"), std::string::npos);
|
||||
EXPECT_NE(DenialMessage(eManageDenial::HIGHER_RANK, eAccountAction::TOOLS).find("higher GM level"), std::string::npos);
|
||||
EXPECT_EQ(DenialMessage(eManageDenial::NONE, eAccountAction::TOOLS), "");
|
||||
EXPECT_NE(LastOperatorMessage("banned").find("banned"), std::string::npos);
|
||||
}
|
||||
|
||||
// ManageDenialNow reads the self_* and manage_equal_rank levels from the config, as any server sees them
|
||||
TEST(AccountRulesTests, ManageDenialNowUsesPermissionLevels) {
|
||||
auto* previous = Game::config;
|
||||
dConfig config("does_not_exist_config.ini");
|
||||
Game::config = &config;
|
||||
|
||||
// Defaults: self_tools GM 1, self_items / self_moderation / manage_equal_rank GM 9
|
||||
EXPECT_EQ(ManageDenialNow(3, 7, 3, 7, eAccountAction::TOOLS), eManageDenial::NONE);
|
||||
EXPECT_EQ(ManageDenialNow(8, 7, 8, 7, eAccountAction::ITEMS), eManageDenial::SELF);
|
||||
EXPECT_EQ(ManageDenialNow(8, 7, 8, 7, eAccountAction::MODERATION), eManageDenial::SELF);
|
||||
EXPECT_EQ(ManageDenialNow(8, 7, 8, 6, eAccountAction::TOOLS), eManageDenial::EQUAL_RANK);
|
||||
|
||||
// Lowered on the Permissions page (values the dashboard stored)
|
||||
config.SetDatabaseValues({ { Permissions::ConfigName("self_items"), "8" }, { Permissions::ConfigName("manage_equal_rank"), "8" } }, {});
|
||||
EXPECT_EQ(ManageDenialNow(8, 7, 8, 7, eAccountAction::ITEMS), eManageDenial::NONE);
|
||||
EXPECT_EQ(ManageDenialNow(8, 7, 8, 6, eAccountAction::TOOLS), eManageDenial::NONE);
|
||||
EXPECT_EQ(ManageDenialNow(8, 7, 9, 6, eAccountAction::TOOLS), eManageDenial::HIGHER_RANK);
|
||||
|
||||
Game::config = previous;
|
||||
}
|
||||
@@ -14,6 +14,18 @@ set(DCOMMONTEST_SOURCES
|
||||
"MessageIdPinTests.cpp"
|
||||
"dCommonDependencies.cpp"
|
||||
"LxfmlTests.cpp"
|
||||
"MigrationSplitTests.cpp"
|
||||
"ConfigLayerTests.cpp"
|
||||
"SlashCommandLevelsTests.cpp"
|
||||
"AccountRulesTests.cpp"
|
||||
"VanityXmlTests.cpp"
|
||||
"EventPartsTests.cpp"
|
||||
"ScheduleRulesTests.cpp"
|
||||
"VanityEventsTests.cpp"
|
||||
"ZoneFileTests.cpp"
|
||||
"LiveOpsRulesTests.cpp"
|
||||
"ProcessAndLimitsTests.cpp"
|
||||
"HotPropertySlotsTests.cpp"
|
||||
)
|
||||
|
||||
add_subdirectory(dEnumsTests)
|
||||
@@ -32,6 +44,8 @@ endif()
|
||||
|
||||
# Link needed libraries
|
||||
target_link_libraries(dCommonTests ${COMMON_LIBRARIES} GTest::gtest_main)
|
||||
# SpareBackoff.h (header only)
|
||||
target_include_directories(dCommonTests PRIVATE "${PROJECT_SOURCE_DIR}/dMasterServer")
|
||||
|
||||
# Copy test files to testing directory
|
||||
add_subdirectory(TestBitStreams)
|
||||
@@ -39,5 +53,8 @@ file(COPY ${TESTBITSTREAMS} DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
|
||||
add_subdirectory(LxfmlTestFiles)
|
||||
file(COPY ${LXFMLTESTFILES} DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
# VanityXmlTests reads the shipped vanity files
|
||||
target_compile_definitions(dCommonTests PRIVATE DLU_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
|
||||
|
||||
# Discover the tests
|
||||
gtest_discover_tests(dCommonTests)
|
||||
|
||||
113
tests/dCommonTests/ConfigLayerTests.cpp
Normal file
113
tests/dCommonTests/ConfigLayerTests.cpp
Normal file
@@ -0,0 +1,113 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "dConfig.h"
|
||||
#include "ConfigSync.h"
|
||||
|
||||
namespace {
|
||||
void SetEnv(const char* name, const char* value) {
|
||||
#ifdef _WIN32
|
||||
_putenv_s(name, value ? value : "");
|
||||
#else
|
||||
if (value) setenv(name, value, 1); else unsetenv(name);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ConfigLayerTests, DatabaseValuesLayerAroundFilesAndEnvironment) {
|
||||
dConfig config("does_not_exist_config.ini");
|
||||
config.SetDatabaseValues({ { "zz_override", "web" }, { "mysql_host", "evil" } }, { { "zz_fallback", "web" }, { "zz_both", "web" } });
|
||||
EXPECT_EQ(config.GetValue("zz_override"), "web");
|
||||
EXPECT_EQ(config.GetValue("zz_fallback"), "web");
|
||||
EXPECT_EQ(config.GetValue("mysql_host"), ""); // connection settings never come from the database
|
||||
EXPECT_EQ(config.GetValue("zz_missing"), "");
|
||||
|
||||
// The environment beats a fallback but not an override
|
||||
SetEnv("ZZ_BOTH", "env");
|
||||
SetEnv("ZZ_OVERRIDE", "env");
|
||||
EXPECT_EQ(config.GetValue("zz_both"), "env");
|
||||
EXPECT_EQ(config.GetValue("zz_override"), "web");
|
||||
SetEnv("ZZ_BOTH", nullptr);
|
||||
SetEnv("ZZ_OVERRIDE", nullptr);
|
||||
}
|
||||
|
||||
TEST(ConfigLayerTests, KeyRules) {
|
||||
EXPECT_TRUE(dConfig::IsFileOnlyKey("mysql_password"));
|
||||
EXPECT_TRUE(dConfig::IsFileOnlyKey("database_type"));
|
||||
EXPECT_TRUE(dConfig::IsFileOnlyKey("jwt_secret"));
|
||||
EXPECT_FALSE(dConfig::IsFileOnlyKey("max_clients"));
|
||||
EXPECT_TRUE(dConfig::IsSecretKey("smtp_password"));
|
||||
EXPECT_TRUE(dConfig::IsSecretKey("smtp_oauth2_client_secret"));
|
||||
EXPECT_TRUE(dConfig::IsSecretKey("totp_key"));
|
||||
EXPECT_FALSE(dConfig::IsSecretKey("smtp_host"));
|
||||
}
|
||||
|
||||
TEST(ConfigLayerTests, ResolveOwnFileFirstAndRespectLocalValues) {
|
||||
auto row = [](std::string file, std::string name, std::optional<std::string> web, bool wins) {
|
||||
IServerConfig::Setting setting;
|
||||
setting.file = std::move(file);
|
||||
setting.name = std::move(name);
|
||||
setting.webValue = std::move(web);
|
||||
setting.webWins = wins;
|
||||
return setting;
|
||||
};
|
||||
const std::vector<IServerConfig::Setting> rows{
|
||||
row("sharedconfig.ini", "max_clients", "50", false),
|
||||
row("worldconfig.ini", "max_clients", "80", false), // own file beats shared for the same key
|
||||
row("sharedconfig.ini", "log_debug_statements", "1", true),
|
||||
row("sharedconfig.ini", "client_location", "/web", false), // set in the file locally: fallback ignored
|
||||
row("sharedconfig.ini", "no_web_value", std::nullopt, true),
|
||||
};
|
||||
const auto resolved = ConfigSync::Resolve(rows, "worldconfig.ini", { "client_location" });
|
||||
EXPECT_EQ(resolved.fallbacks.at("max_clients"), "80");
|
||||
EXPECT_EQ(resolved.overrides.at("log_debug_statements"), "1");
|
||||
EXPECT_FALSE(resolved.fallbacks.contains("client_location"));
|
||||
EXPECT_FALSE(resolved.overrides.contains("no_web_value"));
|
||||
EXPECT_FALSE(resolved.fallbacks.contains("no_web_value"));
|
||||
}
|
||||
|
||||
// The worlds read the dashboard's permission levels from what it stored: its web value, its file or environment, then a web fallback
|
||||
TEST(ConfigLayerTests, OwnedValuesComeFromTheOwner) {
|
||||
auto row = [](std::string name, std::optional<std::string> web, bool wins, std::string source, std::optional<std::string> file) {
|
||||
IServerConfig::Setting setting;
|
||||
setting.file = "dashboardconfig.ini";
|
||||
setting.name = std::move(name);
|
||||
setting.webValue = std::move(web);
|
||||
setting.webWins = wins;
|
||||
setting.fileSource = std::move(source);
|
||||
setting.fileValue = std::move(file);
|
||||
return setting;
|
||||
};
|
||||
std::vector<IServerConfig::Setting> rows{
|
||||
row("permission_accounts_kick", "3", true, "file", "5"), // page beats the file
|
||||
row("permission_accounts_ban", std::nullopt, false, "file", "6"), // the dashboard's file
|
||||
row("permission_accounts_mute", std::nullopt, false, "env", "4"), // the dashboard's environment
|
||||
row("permission_players_view", "2", false, "", std::nullopt), // web value without override: a fallback
|
||||
row("permission_self_items", std::nullopt, false, "", std::nullopt), // cleared: nothing
|
||||
row("dashboard_port", "8080", true, "file", "80"), // not a permission
|
||||
};
|
||||
rows.push_back(row("permission_other_file", "1", true, "", std::nullopt));
|
||||
rows.back().file = "worldconfig.ini";
|
||||
|
||||
ConfigSync::Resolved resolved;
|
||||
resolved.overrides["permission_accounts_ban"] = "9"; // what the reader had: replaced
|
||||
resolved.fallbacks["permission_accounts_kick"] = "8";
|
||||
ConfigSync::AddOwnedValues(resolved, rows, "dashboardconfig.ini", "permission_");
|
||||
EXPECT_EQ(resolved.overrides.at("permission_accounts_kick"), "3");
|
||||
EXPECT_FALSE(resolved.fallbacks.contains("permission_accounts_kick"));
|
||||
EXPECT_EQ(resolved.overrides.at("permission_accounts_ban"), "6");
|
||||
EXPECT_EQ(resolved.overrides.at("permission_accounts_mute"), "4");
|
||||
EXPECT_EQ(resolved.fallbacks.at("permission_players_view"), "2");
|
||||
EXPECT_FALSE(resolved.overrides.contains("permission_players_view"));
|
||||
EXPECT_FALSE(resolved.overrides.contains("permission_self_items"));
|
||||
EXPECT_FALSE(resolved.fallbacks.contains("permission_self_items"));
|
||||
EXPECT_FALSE(resolved.overrides.contains("dashboard_port"));
|
||||
EXPECT_FALSE(resolved.overrides.contains("permission_other_file"));
|
||||
|
||||
// Layered into a world's config: the dashboard's value wins over the world's own files and environment
|
||||
dConfig config("does_not_exist_config.ini");
|
||||
config.SetDatabaseValues(std::move(resolved.overrides), std::move(resolved.fallbacks));
|
||||
EXPECT_EQ(config.GetValue("permission_accounts_ban"), "6");
|
||||
EXPECT_EQ(config.GetValue("permission_players_view"), "2");
|
||||
}
|
||||
98
tests/dCommonTests/EventPartsTests.cpp
Normal file
98
tests/dCommonTests/EventPartsTests.cpp
Normal file
@@ -0,0 +1,98 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "EventParts.h"
|
||||
|
||||
using namespace EventParts;
|
||||
|
||||
namespace {
|
||||
Part Make(eKind kind, nlohmann::json config, bool applied = false) {
|
||||
return { .kind = kind, .config = std::move(config), .applied = applied };
|
||||
}
|
||||
}
|
||||
|
||||
TEST(EventPartsTests, KindNames) {
|
||||
EXPECT_EQ(KindName(eKind::LIVE_EVENT), "live_event");
|
||||
EXPECT_EQ(KindOf("announcement"), eKind::ANNOUNCEMENT);
|
||||
EXPECT_EQ(KindOf("Vanity"), eKind::VANITY);
|
||||
EXPECT_FALSE(KindOf("party").has_value());
|
||||
}
|
||||
|
||||
TEST(EventPartsTests, ParseAndWrite) {
|
||||
std::string error;
|
||||
EXPECT_TRUE(Parse("", error)->empty());
|
||||
// applied may be 1, as the migration from the calendar and vanity events wrote it
|
||||
const auto parts = Parse(R"([{"kind":"feature","config":{"feature":"oct2011content"},"applied":1,"state":{"slot":3},"status":"On in event_3"},{"kind":"vanity"}])", error);
|
||||
ASSERT_TRUE(parts) << error;
|
||||
ASSERT_EQ(parts->size(), 2u);
|
||||
EXPECT_EQ((*parts)[0].kind, eKind::FEATURE);
|
||||
EXPECT_TRUE((*parts)[0].applied);
|
||||
EXPECT_EQ((*parts)[0].state["slot"], 3);
|
||||
EXPECT_FALSE((*parts)[1].applied);
|
||||
EXPECT_TRUE((*parts)[1].config.is_object());
|
||||
|
||||
const auto again = Parse(Write(*parts), error);
|
||||
ASSERT_TRUE(again);
|
||||
EXPECT_EQ(Write(*again), Write(*parts));
|
||||
|
||||
EXPECT_FALSE(Parse("{}", error));
|
||||
EXPECT_FALSE(Parse(R"([{"kind":"party"}])", error));
|
||||
EXPECT_NE(error.find("live_event"), std::string::npos); // says what the kinds are
|
||||
EXPECT_FALSE(Parse("[", error));
|
||||
}
|
||||
|
||||
TEST(EventPartsTests, Steps) {
|
||||
const auto idle = Make(eKind::FEATURE, {});
|
||||
const auto started = Make(eKind::FEATURE, {}, true);
|
||||
EXPECT_EQ(StepFor(idle, true), eStep::START);
|
||||
EXPECT_EQ(StepFor(started, true), eStep::CONTINUE);
|
||||
EXPECT_EQ(StepFor(started, false), eStep::END);
|
||||
EXPECT_EQ(StepFor(idle, false), eStep::NONE);
|
||||
}
|
||||
|
||||
TEST(EventPartsTests, ReconcileKeepsWhatDidntChange) {
|
||||
auto feature = Make(eKind::FEATURE, { {"feature", "oct2011content"} }, true);
|
||||
feature.state = { {"slot", 2} };
|
||||
const auto announcement = Make(eKind::ANNOUNCEMENT, { {"message", "Boo"} }, true);
|
||||
const auto vanity = Make(eKind::VANITY, { {"file", "halloween.xml"} }, false);
|
||||
|
||||
auto live = Make(eKind::LIVE_EVENT, { {"type", "bonus"} }, true);
|
||||
live.state = { {"id", 7} };
|
||||
|
||||
// The feature stays, the announcement's text and the live event changed, the vanity part is gone, a restart is new
|
||||
const auto result = Reconcile({ feature, announcement, vanity, live }, {
|
||||
Make(eKind::ANNOUNCEMENT, { {"message", "Boo!"} }), Make(eKind::FEATURE, { {"feature", "oct2011content"} }), Make(eKind::LIVE_EVENT, { {"type", "invasion"} }, true),
|
||||
Make(eKind::RESTART, { {"when", "end"} }, true) });
|
||||
ASSERT_EQ(result.parts.size(), 4u);
|
||||
EXPECT_TRUE(result.parts[0].applied); // momentary: already said, not said again
|
||||
EXPECT_TRUE(result.parts[1].applied); // the same: keeps its slot
|
||||
EXPECT_EQ(result.parts[1].state["slot"], 2);
|
||||
EXPECT_FALSE(result.parts[2].applied); // changed: starts afresh
|
||||
EXPECT_FALSE(result.parts[3].applied); // new, whatever the page sent
|
||||
// Only started parts that are gone or changed are undone
|
||||
ASSERT_EQ(result.retired.size(), 1u);
|
||||
EXPECT_EQ(result.retired[0].state["id"], 7);
|
||||
|
||||
// Two of the same kind and config are matched one to one
|
||||
const auto twice = Reconcile({ announcement }, { Make(eKind::ANNOUNCEMENT, { {"message", "Boo"} }), Make(eKind::ANNOUNCEMENT, { {"message", "Boo"} }) });
|
||||
EXPECT_TRUE(twice.parts[0].applied);
|
||||
EXPECT_FALSE(twice.parts[1].applied);
|
||||
EXPECT_TRUE(twice.retired.empty());
|
||||
}
|
||||
|
||||
TEST(EventPartsTests, VanityChanges) {
|
||||
const auto changes = VanityChanges("Halloween", R"([
|
||||
{"kind":"feature","config":{"feature":"oct2011content"}},
|
||||
{"kind":"vanity","config":{"file":"halloween-look.xml","removals":["Bob","Alice"],"fileSwitches":{"halloween.xml":true,"summer.xml":false}}},
|
||||
{"kind":"vanity","config":{"removals":"Carl\n","file":7}}])");
|
||||
ASSERT_EQ(changes.size(), 2u);
|
||||
EXPECT_EQ(changes[0].name, "Halloween");
|
||||
EXPECT_EQ(changes[0].file, "halloween-look.xml");
|
||||
EXPECT_EQ(changes[0].removals, "Bob\nAlice\n");
|
||||
std::string error;
|
||||
const auto switches = VanityEvents::ParseFileSwitches(changes[0].fileSwitches, error);
|
||||
ASSERT_TRUE(switches) << error;
|
||||
EXPECT_EQ(switches->at("summer.xml"), false);
|
||||
EXPECT_EQ(changes[1].file, ""); // not a name: ignored rather than failing the world
|
||||
EXPECT_EQ(changes[1].removals, "Carl\n");
|
||||
EXPECT_TRUE(VanityChanges("Broken", "[").empty());
|
||||
}
|
||||
180
tests/dCommonTests/HotPropertySlotsTests.cpp
Normal file
180
tests/dCommonTests/HotPropertySlotsTests.cpp
Normal file
@@ -0,0 +1,180 @@
|
||||
#include "HotPropertySlots.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
using namespace HotPropertySlots;
|
||||
|
||||
namespace {
|
||||
// A cut of the 1.10.64 CDClient: test and unused rows share the spawn names; only the live worlds have entrances
|
||||
const std::vector<TemplateRow> TEMPLATES{
|
||||
{ 25218, 58001, "AGSmallProperty" }, { 25219, 521, "AGSmallProperty" }, { 25220, 162, "GFSmallProperty" },
|
||||
{ 25221, 63, "NSSmallProperty" }, { 25166, 1150, "AGSmallProperty" }, { 25168, 1151, "AGMedProperty" },
|
||||
{ 25188, 1250, "NSSmallProperty" }, { 25189, 1251, "NSMedProperty" }, { 25191, 1350, "GFSmallProperty" },
|
||||
{ 25194, 1450, "FVSmallProperty" }, { 25208, 45, "FVSmallProperty" }, { 25216, 542, "TestPath" },
|
||||
};
|
||||
const std::vector<EntranceRow> ENTRANCES{
|
||||
{ 1150, "AGSmallProperty" }, { 1150, "AGSmallProperty" }, { 1151, "AGMedProperty" }, { 1250, "NSSmallProperty" },
|
||||
{ 1251, "NSMedProperty" }, { 1350, "GFSmallProperty" }, { 1450, "FVSmallProperty" },
|
||||
};
|
||||
}
|
||||
|
||||
// The four slots are the template ids live sent: 25166, 25188, 25191, 25194, in that order
|
||||
TEST(HotPropertySlotsTests, ResolvesTheFourLiveSlots) {
|
||||
const auto slots = ResolveSlots(TEMPLATES, ENTRANCES);
|
||||
const std::vector<Slot> expected{
|
||||
{ 25166, 1150, "AGSmallProperty" }, { 25188, 1250, "NSSmallProperty" }, { 25191, 1350, "GFSmallProperty" }, { 25194, 1450, "FVSmallProperty" },
|
||||
};
|
||||
EXPECT_EQ(slots, expected);
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, WorldsTheNewsScreenCannotShowHaveNoSlot) {
|
||||
for (const auto& slot : ResolveSlots(TEMPLATES, ENTRANCES)) {
|
||||
EXPECT_NE(slot.mapId, 1151u);
|
||||
EXPECT_NE(slot.mapId, 1251u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, SlotWithoutEntranceIsLeftOut) {
|
||||
const std::vector<EntranceRow> entrances{ { 1250, "NSSmallProperty" }, { 1450, "FVSmallProperty" } };
|
||||
const auto slots = ResolveSlots(TEMPLATES, entrances);
|
||||
ASSERT_EQ(slots.size(), 2u);
|
||||
EXPECT_EQ(slots[0].templateId, 25188u);
|
||||
EXPECT_EQ(slots[1].templateId, 25194u);
|
||||
EXPECT_TRUE(ResolveSlots(TEMPLATES, {}).empty());
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, LowestTemplateIdWinsWhenSeveralMatch) {
|
||||
const std::vector<TemplateRow> templates{ { 30000, 1150, "AGSmallProperty" }, { 25166, 1150, "AGSmallProperty" } };
|
||||
const auto slots = ResolveSlots(templates, ENTRANCES);
|
||||
ASSERT_EQ(slots.size(), 1u);
|
||||
EXPECT_EQ(slots[0].templateId, 25166u);
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, OnlyApprovedPublicPropertiesOfTheSlotsWorld) {
|
||||
EXPECT_TRUE(Featurable(1, 2, 1150, 1150));
|
||||
EXPECT_FALSE(Featurable(0, 2, 1150, 1150)); // awaiting review
|
||||
EXPECT_FALSE(Featurable(2, 2, 1150, 1150));
|
||||
EXPECT_FALSE(Featurable(1, 1, 1150, 1150)); // friends only
|
||||
EXPECT_FALSE(Featurable(1, 0, 1150, 1150)); // private
|
||||
EXPECT_FALSE(Featurable(1, 2, 1151, 1150)); // another world
|
||||
}
|
||||
|
||||
// news.gfx always walks four entries and reuses the previous slot for a missing one
|
||||
TEST(HotPropertySlotsTests, NewsOrderPadsWithTheLastEntry) {
|
||||
EXPECT_TRUE(NewsOrder(0).empty());
|
||||
EXPECT_EQ(NewsOrder(1), (std::vector<size_t>{ 0, 0, 0, 0 }));
|
||||
EXPECT_EQ(NewsOrder(2), (std::vector<size_t>{ 0, 1, 1, 1 }));
|
||||
EXPECT_EQ(NewsOrder(3), (std::vector<size_t>{ 0, 1, 2, 2 }));
|
||||
EXPECT_EQ(NewsOrder(4), (std::vector<size_t>{ 0, 1, 2, 3 }));
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, Modes) {
|
||||
for (const auto mode : { eMode::AUTO, eMode::PICKED, eMode::EMPTY }) {
|
||||
EXPECT_EQ(ParseMode(ModeName(mode)), mode);
|
||||
EXPECT_EQ(ModeFromInt(static_cast<int64_t>(mode)), mode);
|
||||
}
|
||||
EXPECT_EQ(ParseMode("bogus"), std::nullopt);
|
||||
EXPECT_EQ(ModeFromInt(7), eMode::AUTO);
|
||||
// Stored values the database keeps; never renumber
|
||||
EXPECT_EQ(static_cast<int>(eMode::AUTO), 0);
|
||||
EXPECT_EQ(static_cast<int>(eMode::PICKED), 1);
|
||||
EXPECT_EQ(static_cast<int>(eMode::EMPTY), 2);
|
||||
}
|
||||
|
||||
// Every property world players can launch to, small and medium; test and unused maps are left out
|
||||
TEST(HotPropertySlotsTests, PropertyWorldsAreTheEnteredTemplateMaps) {
|
||||
EXPECT_EQ(PropertyWorlds(TEMPLATES, ENTRANCES), (std::vector<uint32_t>{ 1150, 1151, 1250, 1251, 1350, 1450 }));
|
||||
EXPECT_TRUE(PropertyWorlds(TEMPLATES, {}).empty());
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, LocationDefaultsToTheSlotsWorld) {
|
||||
const auto worlds = PropertyWorlds(TEMPLATES, ENTRANCES);
|
||||
const Slot blockYard{ 25166, 1150, "AGSmallProperty" };
|
||||
EXPECT_EQ(Location(0, blockYard, worlds), 1150u); // rows from before locations
|
||||
EXPECT_EQ(Location(1251, blockYard, worlds), 1251u); // Nimbus Isle
|
||||
EXPECT_EQ(Location(58001, blockYard, worlds), 1150u); // not a property world (any more)
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Approved public properties: 1xx on Block Yard (1150), 2xx on Avant Grove (1151), 3xx on Nimbus Rock (1250)
|
||||
const std::vector<Candidate> CANDIDATES{
|
||||
{ 101, 1150, 500 }, { 102, 1150, 400 }, { 103, 1150, 300 }, { 201, 1151, 900 }, { 202, 1151, 50 }, { 301, 1250, 700 },
|
||||
};
|
||||
|
||||
std::vector<std::optional<int64_t>> Shown(const std::vector<Showing>& showing) {
|
||||
std::vector<std::optional<int64_t>> ids;
|
||||
for (const auto& slot : showing) ids.push_back(slot.propertyId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
using Ids = std::vector<std::optional<int64_t>>;
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, AutoShowsEachLocationsBest) {
|
||||
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::AUTO, 1250 }, { eMode::AUTO, 1350 }, { eMode::AUTO, 1151 } };
|
||||
EXPECT_EQ(Shown(Resolve(choices, false, CANDIDATES)), (Ids{ 101, 301, std::nullopt, 201 }));
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, AutosOnOneLocationShowItsFirstAndSecond) {
|
||||
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 } };
|
||||
EXPECT_EQ(Shown(Resolve(choices, false, CANDIDATES)), (Ids{ 101, 102, 103, std::nullopt }));
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, PicksComeFirstAndAutosSkipThem) {
|
||||
// The first slot's auto would be 101, but a later slot picked it
|
||||
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::PICKED, 1150, 101 }, { eMode::EMPTY, 1350 }, { eMode::AUTO, 1150 } };
|
||||
const auto showing = Resolve(choices, false, CANDIDATES);
|
||||
EXPECT_EQ(Shown(showing), (Ids{ 102, 101, std::nullopt, 103 }));
|
||||
EXPECT_FALSE(showing[1].pickFellBack);
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, APropertyPickedTwiceIsShownOnce) {
|
||||
const std::vector<Choice> choices{ { eMode::PICKED, 1150, 103 }, { eMode::PICKED, 1150, 103 }, { eMode::AUTO, 1250 }, { eMode::AUTO, 1250 } };
|
||||
const auto showing = Resolve(choices, false, CANDIDATES);
|
||||
EXPECT_EQ(Shown(showing), (Ids{ 103, 101, 301, std::nullopt }));
|
||||
EXPECT_FALSE(showing[0].pickFellBack);
|
||||
EXPECT_TRUE(showing[1].pickFellBack);
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, AnUnavailablePickFallsBackToAuto) {
|
||||
// 999 is no candidate (no longer approved or public); 301 is, but not of the slot's location
|
||||
const std::vector<Choice> choices{ { eMode::PICKED, 1150, 999 }, { eMode::PICKED, 1150, 301 }, { eMode::AUTO, 1150 }, { eMode::EMPTY, 1450 } };
|
||||
const auto showing = Resolve(choices, false, CANDIDATES);
|
||||
EXPECT_EQ(Shown(showing), (Ids{ 101, 102, 103, std::nullopt }));
|
||||
EXPECT_TRUE(showing[0].pickFellBack);
|
||||
EXPECT_TRUE(showing[1].pickFellBack);
|
||||
EXPECT_FALSE(showing[2].pickFellBack);
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, APickOfAnotherWorld) {
|
||||
const std::vector<Choice> choices{ { eMode::PICKED, 1151, 202 }, { eMode::AUTO, 1151 }, { eMode::AUTO, 1151 }, { eMode::AUTO, 1250 } };
|
||||
EXPECT_EQ(Shown(Resolve(choices, false, CANDIDATES)), (Ids{ 202, 201, std::nullopt, 301 }));
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, FullAutoShowsTheTopFourAcrossEveryWorld) {
|
||||
// The choices don't matter
|
||||
const std::vector<Choice> choices{ { eMode::EMPTY, 1150 }, { eMode::PICKED, 1150, 103 }, { eMode::AUTO, 1350 }, { eMode::AUTO, 1450 } };
|
||||
const auto showing = Resolve(choices, true, CANDIDATES);
|
||||
EXPECT_EQ(Shown(showing), (Ids{ 201, 301, 101, 102 }));
|
||||
for (const auto& slot : showing) EXPECT_FALSE(slot.pickFellBack);
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, FullAutoWithFewerPropertiesThanSlots) {
|
||||
const std::vector<Choice> choices(4);
|
||||
EXPECT_EQ(Shown(Resolve(choices, true, { { 202, 1151, 50 }, { 301, 1250, 700 } })), (Ids{ 301, 202, std::nullopt, std::nullopt }));
|
||||
EXPECT_EQ(Shown(Resolve(choices, true, {})), (Ids(4, std::nullopt)));
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, ACandidateListedTwiceCountsOnce) {
|
||||
// A picked property is added to the candidates even when a world's list already has it
|
||||
auto candidates = CANDIDATES;
|
||||
candidates.push_back({ 101, 1150, 500 });
|
||||
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 }, { eMode::AUTO, 1150 } };
|
||||
EXPECT_EQ(Shown(Resolve(choices, false, candidates)), (Ids{ 101, 102, 103, std::nullopt }));
|
||||
}
|
||||
|
||||
TEST(HotPropertySlotsTests, CandidateWorlds) {
|
||||
const std::vector<Choice> choices{ { eMode::AUTO, 1150 }, { eMode::PICKED, 1151, 202 }, { eMode::EMPTY, 1350 }, { eMode::AUTO, 1150 } };
|
||||
EXPECT_EQ(CandidateWorlds(choices, false), (std::vector<uint32_t>{ 1150, 1151 }));
|
||||
EXPECT_EQ(CandidateWorlds(choices, true), (std::vector<uint32_t>{ 0 }));
|
||||
}
|
||||
153
tests/dCommonTests/LiveOpsRulesTests.cpp
Normal file
153
tests/dCommonTests/LiveOpsRulesTests.cpp
Normal file
@@ -0,0 +1,153 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <random>
|
||||
|
||||
#include "LiveOpsRules.h"
|
||||
|
||||
using namespace LiveOpsRules;
|
||||
|
||||
namespace {
|
||||
std::vector<NiPoint3> Line(int count, float step) {
|
||||
std::vector<NiPoint3> points;
|
||||
for (int i = 0; i < count; i++) points.emplace_back(static_cast<float>(i) * step, 0.0f, 0.0f);
|
||||
return points;
|
||||
}
|
||||
|
||||
float MinDistance(const std::vector<NiPoint3>& points) {
|
||||
float best = 1e9f;
|
||||
for (size_t i = 0; i < points.size(); i++) {
|
||||
for (size_t j = i + 1; j < points.size(); j++) best = std::min(best, NiPoint3::Distance(points[i], points[j]));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, EventTypeNamesRoundTrip) {
|
||||
for (const auto type : { eEventType::TREASURE_HUNT, eEventType::BONUS, eEventType::INVASION, eEventType::CELEBRATION }) {
|
||||
EXPECT_EQ(ParseType(TypeName(type)), type);
|
||||
}
|
||||
EXPECT_FALSE(ParseType("party").has_value());
|
||||
EXPECT_FALSE(NeedsZone(eEventType::BONUS));
|
||||
EXPECT_TRUE(NeedsZone(eEventType::TREASURE_HUNT));
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, PlacementOnlyUsesCandidatesAndKeepsThemApart) {
|
||||
std::mt19937 rng(42);
|
||||
const auto candidates = Line(20, 10.0f); // 10 apart
|
||||
const auto chosen = ChoosePositions(candidates, { .count = 5, .minSpacing = 25.0f }, rng);
|
||||
ASSERT_EQ(chosen.size(), 5u);
|
||||
EXPECT_GE(MinDistance(chosen), 25.0f);
|
||||
for (const auto& point : chosen) EXPECT_NE(std::find(candidates.begin(), candidates.end(), point), candidates.end());
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, PlacementRelaxesSpacingRatherThanGivingUp) {
|
||||
std::mt19937 rng(1);
|
||||
const auto chosen = ChoosePositions(Line(6, 2.0f), { .count = 6, .minSpacing = 50.0f }, rng);
|
||||
EXPECT_EQ(chosen.size(), 6u);
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, PlacementNeverInventsPositionsWithoutReuse) {
|
||||
std::mt19937 rng(7);
|
||||
EXPECT_EQ(ChoosePositions(Line(3, 10.0f), { .count = 10, .minSpacing = 1.0f }, rng).size(), 3u);
|
||||
EXPECT_TRUE(ChoosePositions({}, { .count = 10 }, rng).empty());
|
||||
EXPECT_TRUE(ChoosePositions(Line(3, 10.0f), { .count = 0 }, rng).empty());
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, PlacementAroundACenterCyclesWhenReusing) {
|
||||
std::mt19937 rng(3);
|
||||
const auto chosen = ChoosePositions(Line(20, 10.0f), { .count = 8, .minSpacing = 1.0f, .center = NiPoint3(0.0f, 0.0f, 0.0f), .radius = 15.0f, .reuse = true }, rng);
|
||||
ASSERT_EQ(chosen.size(), 8u);
|
||||
for (const auto& point : chosen) EXPECT_LE(point.x, 15.0f); // only the two within the radius, used again
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, MultipliersApplyOnlyInTheirWindowAndDontStack) {
|
||||
const std::vector<BonusWindow> windows{
|
||||
{ 100, 200, { 2.0f, 1.0f, 1.0f } },
|
||||
{ 150, 300, { 3.0f, 1.5f, 1.0f } },
|
||||
{ 0, 1000, { 50.0f, 0.5f, 1.0f } }, // clamped to 10, and below 1 means no change
|
||||
};
|
||||
EXPECT_EQ(ActiveMultipliers({}, 120), Multipliers{});
|
||||
EXPECT_FALSE(ActiveMultipliers({ windows[0] }, 99).Any());
|
||||
EXPECT_EQ(ActiveMultipliers({ windows[0] }, 100).coins, 2.0f);
|
||||
EXPECT_FALSE(ActiveMultipliers({ windows[0] }, 200).Any()); // the end is exclusive
|
||||
const auto both = ActiveMultipliers({ windows[0], windows[1] }, 160);
|
||||
EXPECT_EQ(both.coins, 3.0f);
|
||||
EXPECT_EQ(both.uscore, 1.5f);
|
||||
EXPECT_EQ(ActiveMultipliers({ windows[2] }, 500).coins, MAX_MULTIPLIER);
|
||||
EXPECT_EQ(ActiveMultipliers({ windows[2] }, 500).uscore, 1.0f);
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, ScalingRoundsSaturatesAndLeavesNoBonusAlone) {
|
||||
EXPECT_EQ(Scale<uint32_t>(15, 1.0f), 15u);
|
||||
EXPECT_EQ(Scale<uint32_t>(15, 2.0f), 30u);
|
||||
EXPECT_EQ(Scale<uint32_t>(15, 1.5f), 23u);
|
||||
EXPECT_EQ(Scale<uint32_t>(0, 3.0f), 0u);
|
||||
EXPECT_EQ(Scale<uint32_t>(4000000000u, 2.0f), UINT32_MAX);
|
||||
EXPECT_EQ(Scale<int64_t>(-5, 2.0f), -5);
|
||||
EXPECT_FLOAT_EQ(ScaleChance(0.3f, 2.0f), 0.6f);
|
||||
EXPECT_FLOAT_EQ(ScaleChance(0.7f, 2.0f), 1.0f);
|
||||
EXPECT_FLOAT_EQ(ScaleChance(0.7f, 1.0f), 0.7f);
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, WavesComeAtTheStartAndThenEveryInterval) {
|
||||
EXPECT_EQ(WavesDue(100, 60, 3, 99), 0u);
|
||||
EXPECT_EQ(WavesDue(100, 60, 3, 100), 1u);
|
||||
EXPECT_EQ(WavesDue(100, 60, 3, 159), 1u);
|
||||
EXPECT_EQ(WavesDue(100, 60, 3, 160), 2u);
|
||||
EXPECT_EQ(WavesDue(100, 60, 3, 10000), 3u);
|
||||
EXPECT_EQ(WavesDue(100, 60, 0, 10000), 0u);
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, ChallengePhaseAndPercent) {
|
||||
EXPECT_EQ(Phase(0, 100, 200, 50), ePhase::SCHEDULED);
|
||||
EXPECT_EQ(Phase(0, 100, 200, 150), ePhase::ACTIVE);
|
||||
EXPECT_EQ(Phase(0, 100, 200, 200), ePhase::EXPIRED);
|
||||
EXPECT_EQ(Phase(1, 100, 200, 150), ePhase::COMPLETED);
|
||||
EXPECT_EQ(Phase(3, 100, 200, 150), ePhase::CANCELLED);
|
||||
EXPECT_EQ(Percent(0, 1000), 0);
|
||||
EXPECT_EQ(Percent(250, 1000), 25);
|
||||
EXPECT_EQ(Percent(999, 1000), 99); // 100 only when reached
|
||||
EXPECT_EQ(Percent(1000, 1000), 100);
|
||||
EXPECT_EQ(Percent(5000, 1000), 100);
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, MilestonesAnnounceTheHighestNewOneOnce) {
|
||||
const auto milestones = ParseMilestones("75, 25,50,abc,50,0,101,100");
|
||||
EXPECT_EQ(milestones, (std::vector<uint8_t>{ 25, 50, 75, 100 }));
|
||||
EXPECT_EQ(ParseMilestones(""), DefaultMilestones());
|
||||
EXPECT_FALSE(NextMilestone(milestones, 0, 200, 1000).has_value());
|
||||
EXPECT_EQ(NextMilestone(milestones, 0, 250, 1000), 25);
|
||||
EXPECT_FALSE(NextMilestone(milestones, 25, 300, 1000).has_value());
|
||||
EXPECT_EQ(NextMilestone(milestones, 25, 800, 1000), 75); // jumped past 50: one message
|
||||
EXPECT_EQ(NextMilestone(milestones, 75, 1000, 1000), 100);
|
||||
EXPECT_FALSE(NextMilestone(milestones, 100, 1000, 1000).has_value());
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, RewardsGoToThoseWhoContributedEnoughOnce) {
|
||||
EXPECT_FALSE(Eligible(0, 1));
|
||||
EXPECT_TRUE(Eligible(1, 1));
|
||||
EXPECT_TRUE(Eligible(1, 0)); // a minimum below 1 still needs something
|
||||
EXPECT_FALSE(Eligible(9, 10));
|
||||
const std::vector<std::pair<int64_t, int64_t>> contributions{ { 1, 5 }, { 2, 50 }, { 3, 10 }, { 4, 0 }, { 5, 10 } };
|
||||
const auto recipients = RewardRecipients(contributions, 10, std::vector<int64_t>{ 5 });
|
||||
ASSERT_EQ(recipients.size(), 2u);
|
||||
EXPECT_EQ(recipients[0].first, 2); // highest first
|
||||
EXPECT_EQ(recipients[1].first, 3);
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, ZonesLimitWhatCounts) {
|
||||
EXPECT_TRUE(CountsInZone({}, 1100));
|
||||
EXPECT_TRUE(CountsInZone({ 1100, 1200 }, 1200));
|
||||
EXPECT_FALSE(CountsInZone({ 1100 }, 1200));
|
||||
}
|
||||
|
||||
TEST(LiveOpsRulesTests, EndAnnouncementDueOnceAndOnlyWhileRecent) {
|
||||
constexpr int64_t now = 1'000'000;
|
||||
EXPECT_TRUE(EndAnnouncementDue(1, 0, now - 4, now)); // completed during a restart
|
||||
EXPECT_TRUE(EndAnnouncementDue(2, 0, now - 60, now)); // expired
|
||||
EXPECT_FALSE(EndAnnouncementDue(1, now - 4, now - 4, now)); // announced already
|
||||
EXPECT_FALSE(EndAnnouncementDue(0, 0, now - 4, now)); // still open
|
||||
EXPECT_FALSE(EndAnnouncementDue(3, 0, now - 4, now)); // cancelled: never announced
|
||||
EXPECT_FALSE(EndAnnouncementDue(1, 0, now - END_ANNOUNCEMENT_WINDOW - 1, now)); // too long ago to be news
|
||||
EXPECT_FALSE(EndAnnouncementDue(1, 0, 0, now)); // no end time
|
||||
}
|
||||
46
tests/dCommonTests/MigrationSplitTests.cpp
Normal file
46
tests/dCommonTests/MigrationSplitTests.cpp
Normal file
@@ -0,0 +1,46 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "MigrationRunner.h"
|
||||
|
||||
using MigrationRunner::SplitStatements;
|
||||
|
||||
TEST(MigrationSplitTests, SplitsAtStatementEnds) {
|
||||
const auto statements = SplitStatements("CREATE TABLE a (x INT);\nINSERT INTO a VALUES (1);\n\n", false);
|
||||
ASSERT_EQ(statements.size(), 2u);
|
||||
EXPECT_EQ(statements[0], "CREATE TABLE a (x INT)");
|
||||
EXPECT_EQ(statements[1], "\nINSERT INTO a VALUES (1)");
|
||||
}
|
||||
|
||||
TEST(MigrationSplitTests, IgnoresSemicolonsInComments) {
|
||||
const auto statements = SplitStatements(
|
||||
"/* Dashboard data; see the other file. */\nCREATE TABLE a (x INT); -- trailing; note\n-- whole line; comment\nCREATE TABLE b (y INT);", false);
|
||||
ASSERT_EQ(statements.size(), 2u);
|
||||
EXPECT_NE(statements[0].find("CREATE TABLE a"), std::string::npos);
|
||||
EXPECT_EQ(statements[0].find("Dashboard"), std::string::npos); // comments are dropped
|
||||
EXPECT_NE(statements[1].find("CREATE TABLE b"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(MigrationSplitTests, IgnoresSemicolonsInQuotes) {
|
||||
const auto statements = SplitStatements("INSERT INTO t VALUES ('a;b', \"c;d\", `e;f`, 'it''s; fine');SELECT 1;", false);
|
||||
ASSERT_EQ(statements.size(), 2u);
|
||||
EXPECT_EQ(statements[0], "INSERT INTO t VALUES ('a;b', \"c;d\", `e;f`, 'it''s; fine')");
|
||||
// A -- or /* inside a string is text, not a comment
|
||||
EXPECT_EQ(SplitStatements("SELECT '--;/*';", false)[0], "SELECT '--;/*'");
|
||||
}
|
||||
|
||||
TEST(MigrationSplitTests, BackslashesDependOnTheDatabase) {
|
||||
// SQLite: a backslash is an ordinary character, so the quote after it closes the string
|
||||
const auto sqlite = SplitStatements("INSERT INTO t VALUES ('scripts\\');SELECT 1;", false);
|
||||
EXPECT_EQ(sqlite.size(), 2u);
|
||||
// MySQL: \' is an escaped quote inside the string
|
||||
const auto mysql = SplitStatements("INSERT INTO t VALUES ('it\\'s; here');SELECT 1;", true);
|
||||
ASSERT_EQ(mysql.size(), 2u);
|
||||
EXPECT_EQ(mysql[0], "INSERT INTO t VALUES ('it\\'s; here')");
|
||||
}
|
||||
|
||||
TEST(MigrationSplitTests, UnterminatedPartsDoNotLoop) {
|
||||
EXPECT_EQ(SplitStatements("SELECT 1; /* never closed", false).size(), 1u);
|
||||
EXPECT_EQ(SplitStatements("SELECT 'never closed", false).size(), 1u);
|
||||
EXPECT_TRUE(SplitStatements("", false).empty());
|
||||
EXPECT_TRUE(SplitStatements(" ;; \n", false).empty());
|
||||
}
|
||||
75
tests/dCommonTests/ProcessAndLimitsTests.cpp
Normal file
75
tests/dCommonTests/ProcessAndLimitsTests.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include "GeneralUtils.h"
|
||||
#include "Process.h"
|
||||
#include "SpareBackoff.h"
|
||||
|
||||
TEST(LikeEscapeTests, WildcardsAreLiteral) {
|
||||
EXPECT_EQ(GeneralUtils::LikeEscape("50%_off\\"), "50\\%\\_off\\\\");
|
||||
EXPECT_EQ(GeneralUtils::LikeEscape("50%_off!", '!'), "50!%!_off!!");
|
||||
EXPECT_EQ(GeneralUtils::LikeEscape("plain", '!'), "plain");
|
||||
}
|
||||
|
||||
TEST(ProcessTests, ShellMetacharactersAreDetected) {
|
||||
EXPECT_FALSE(Process::HasShellMetacharacters("/home/me/LEGO Universe (unpacked)/res"));
|
||||
EXPECT_FALSE(Process::HasShellMetacharacters("C:\\Games\\LU\\client"));
|
||||
EXPECT_TRUE(Process::HasShellMetacharacters("/tmp/$(touch pwned)"));
|
||||
EXPECT_TRUE(Process::HasShellMetacharacters("/tmp/`id`"));
|
||||
EXPECT_TRUE(Process::HasShellMetacharacters("a\"b"));
|
||||
EXPECT_TRUE(Process::HasShellMetacharacters("a; rm -rf /"));
|
||||
EXPECT_TRUE(Process::HasShellMetacharacters("a\nb"));
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST(ProcessTests, RunsWithoutAShell) {
|
||||
const auto dir = std::filesystem::temp_directory_path() / "dlu_process_test";
|
||||
std::filesystem::remove_all(dir);
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto marker = dir / "pwned";
|
||||
const auto out = dir / "out.txt";
|
||||
// $(...) is handed to echo as text, not run
|
||||
EXPECT_EQ(Process::Run({ "echo", "$(touch " + marker.string() + ")" }, out.string()), 0);
|
||||
EXPECT_FALSE(std::filesystem::exists(marker));
|
||||
std::ifstream in(out);
|
||||
std::string line;
|
||||
std::getline(in, line);
|
||||
EXPECT_EQ(line, "$(touch " + marker.string() + ")");
|
||||
EXPECT_NE(Process::Run({ "false" }), 0);
|
||||
EXPECT_NE(Process::Run({ "/no/such/program" }), 0);
|
||||
EXPECT_EQ(Process::Run({}), -1);
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(SpareBackoffTests, CrashingSparesWaitLongerEachTime) {
|
||||
SpareBackoff backoff;
|
||||
EXPECT_TRUE(backoff.CanStart(0));
|
||||
backoff.Started(1);
|
||||
EXPECT_FALSE(backoff.CanStart(1)); // still starting
|
||||
backoff.Lost(10);
|
||||
EXPECT_FALSE(backoff.CanStart(15));
|
||||
EXPECT_TRUE(backoff.CanStart(10 + SpareBackoff::BASE_DELAY));
|
||||
backoff.Started(2);
|
||||
backoff.Lost(100);
|
||||
EXPECT_FALSE(backoff.CanStart(100 + SpareBackoff::BASE_DELAY));
|
||||
EXPECT_TRUE(backoff.CanStart(100 + 2 * SpareBackoff::BASE_DELAY));
|
||||
EXPECT_EQ(SpareBackoff::Delay(50), SpareBackoff::MAX_DELAY);
|
||||
}
|
||||
|
||||
TEST(SpareBackoffTests, OneThatDiesSoonAfterStartingStillCounts) {
|
||||
SpareBackoff backoff;
|
||||
backoff.Started(1);
|
||||
backoff.Running(true, 100);
|
||||
EXPECT_TRUE(backoff.CanStart(101)); // ready: another may start if needed
|
||||
backoff.Lost(160); // but it crashed within STABLE
|
||||
EXPECT_EQ(backoff.Failures(), 1u);
|
||||
|
||||
backoff.Started(2);
|
||||
backoff.Running(true, 1000);
|
||||
backoff.Running(true, 1000 + SpareBackoff::STABLE);
|
||||
EXPECT_EQ(backoff.Failures(), 0u); // ran long enough: the zone works
|
||||
EXPECT_EQ(backoff.Watched(), 0u);
|
||||
}
|
||||
246
tests/dCommonTests/ScheduleRulesTests.cpp
Normal file
246
tests/dCommonTests/ScheduleRulesTests.cpp
Normal file
@@ -0,0 +1,246 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "CivilDate.h"
|
||||
#include "ScheduleRules.h"
|
||||
|
||||
using namespace ScheduleRules;
|
||||
|
||||
namespace {
|
||||
int64_t Utc(int64_t year, unsigned month, unsigned day, int hour = 0, int minute = 0) {
|
||||
return CivilDate::DaysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60;
|
||||
}
|
||||
|
||||
Schedule Parse(const std::string& json) {
|
||||
std::string error;
|
||||
const auto schedule = ParseSchedule(json, error);
|
||||
EXPECT_TRUE(schedule) << json << ": " << error;
|
||||
return schedule.value_or(Schedule{});
|
||||
}
|
||||
|
||||
std::string ErrorOf(const std::string& json) {
|
||||
std::string error;
|
||||
EXPECT_FALSE(ParseSchedule(json, error)) << json;
|
||||
return error;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, YearlyRangeIsWholeDays) {
|
||||
const auto october = Parse(R"({"rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}]})");
|
||||
EXPECT_FALSE(Active(october, Utc(2026, 9, 30, 23, 59)));
|
||||
EXPECT_TRUE(Active(october, Utc(2026, 10, 1)));
|
||||
EXPECT_TRUE(Active(october, Utc(2026, 10, 31, 23, 59)));
|
||||
EXPECT_FALSE(Active(october, Utc(2026, 11, 1)));
|
||||
EXPECT_TRUE(Active(october, Utc(2031, 10, 15, 12)));
|
||||
EXPECT_EQ(NextChange(october, Utc(2026, 9, 26, 12)), Utc(2026, 10, 1));
|
||||
EXPECT_EQ(NextChange(october, Utc(2026, 10, 1)), Utc(2026, 11, 1));
|
||||
EXPECT_EQ(NextChange(october, Utc(2026, 11, 1)), Utc(2027, 10, 1));
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, YearlyRangeWrapsTheYearEnd) {
|
||||
const auto holidays = Parse(R"({"rules": [{"type": "yearly", "from": "12-20", "to": "01-02"}]})");
|
||||
EXPECT_FALSE(Active(holidays, Utc(2026, 12, 19, 23, 59)));
|
||||
EXPECT_TRUE(Active(holidays, Utc(2026, 12, 20)));
|
||||
EXPECT_TRUE(Active(holidays, Utc(2026, 12, 31, 23, 59)));
|
||||
EXPECT_TRUE(Active(holidays, Utc(2027, 1, 1)));
|
||||
EXPECT_TRUE(Active(holidays, Utc(2027, 1, 2, 23, 59)));
|
||||
EXPECT_FALSE(Active(holidays, Utc(2027, 1, 3)));
|
||||
EXPECT_FALSE(Active(holidays, Utc(2027, 6, 1)));
|
||||
EXPECT_EQ(NextChange(holidays, Utc(2026, 12, 25)), Utc(2027, 1, 3));
|
||||
const auto windows = Windows(holidays, Utc(2026, 1, 1), Utc(2028, 1, 1));
|
||||
ASSERT_EQ(windows.size(), 3u); // the end of the last one, all of this one, the start of the next
|
||||
EXPECT_EQ(windows[0].start, Utc(2026, 1, 1));
|
||||
EXPECT_EQ(windows[0].end, Utc(2026, 1, 3));
|
||||
EXPECT_EQ(windows[1].start, Utc(2026, 12, 20));
|
||||
EXPECT_EQ(windows[1].end, Utc(2027, 1, 3));
|
||||
EXPECT_EQ(windows[2].start, Utc(2027, 12, 20));
|
||||
EXPECT_EQ(windows[2].end, Utc(2028, 1, 1));
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, LeapDay) {
|
||||
const auto leap = Parse(R"({"rules": [{"type": "yearly", "from": "02-29", "to": "02-29"}]})");
|
||||
EXPECT_TRUE(Active(leap, Utc(2028, 2, 29, 12)));
|
||||
EXPECT_FALSE(Active(leap, Utc(2028, 3, 1)));
|
||||
EXPECT_FALSE(Active(leap, Utc(2027, 2, 28)));
|
||||
EXPECT_EQ(NextChange(leap, Utc(2026, 9, 26)), Utc(2028, 2, 29));
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, OneOffDates) {
|
||||
// A date alone as the end is the whole of that day
|
||||
const auto days = Parse(R"({"rules": [{"type": "dates", "from": "2026-12-20", "to": "2027-01-02"}]})");
|
||||
EXPECT_FALSE(Active(days, Utc(2026, 12, 19, 23, 59)));
|
||||
EXPECT_TRUE(Active(days, Utc(2026, 12, 20)));
|
||||
EXPECT_TRUE(Active(days, Utc(2027, 1, 2, 23, 59)));
|
||||
EXPECT_FALSE(Active(days, Utc(2027, 1, 3)));
|
||||
EXPECT_FALSE(Active(days, Utc(2027, 12, 25))); // once only
|
||||
EXPECT_EQ(NextChange(days, Utc(2027, 1, 3)), std::nullopt);
|
||||
|
||||
const auto times = Parse(R"({"rules": [{"type": "dates", "from": "2026-10-31T18:00", "to": "2026-11-01T02:00"}]})");
|
||||
EXPECT_FALSE(Active(times, Utc(2026, 10, 31, 17, 59)));
|
||||
EXPECT_TRUE(Active(times, Utc(2026, 10, 31, 18)));
|
||||
EXPECT_FALSE(Active(times, Utc(2026, 11, 1, 2)));
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, WeekdaysAndTimesOfDay) {
|
||||
// 2026-09-25 is a Friday
|
||||
const auto weekend = Parse(R"({"rules": [{"type": "weekdays", "days": ["sat", "Sunday", 0]}]})");
|
||||
EXPECT_FALSE(Active(weekend, Utc(2026, 9, 25, 23, 59)));
|
||||
EXPECT_TRUE(Active(weekend, Utc(2026, 9, 26)));
|
||||
EXPECT_TRUE(Active(weekend, Utc(2026, 9, 27, 23, 59)));
|
||||
EXPECT_FALSE(Active(weekend, Utc(2026, 9, 28)));
|
||||
EXPECT_EQ(NextChange(weekend, Utc(2026, 9, 26, 10)), Utc(2026, 9, 28));
|
||||
|
||||
// Past midnight
|
||||
const auto night = Parse(R"({"rules": [{"type": "time_of_day", "from": "22:00", "to": "02:00"}]})");
|
||||
EXPECT_TRUE(Active(night, Utc(2026, 9, 26, 23)));
|
||||
EXPECT_TRUE(Active(night, Utc(2026, 9, 27, 1, 59)));
|
||||
EXPECT_FALSE(Active(night, Utc(2026, 9, 27, 2)));
|
||||
EXPECT_FALSE(Active(night, Utc(2026, 9, 27, 21, 59)));
|
||||
EXPECT_EQ(NextChange(night, Utc(2026, 9, 27, 12)), Utc(2026, 9, 27, 22));
|
||||
|
||||
// Friday evenings: every rule has to match
|
||||
const auto fridays = Parse(R"({"match": "all", "rules": [{"type": "weekdays", "days": ["fri"]}, {"type": "time_of_day", "from": "18:00", "to": "24:00"}]})");
|
||||
EXPECT_TRUE(Active(fridays, Utc(2026, 9, 25, 20)));
|
||||
EXPECT_FALSE(Active(fridays, Utc(2026, 9, 25, 17)));
|
||||
EXPECT_FALSE(Active(fridays, Utc(2026, 9, 26, 20)));
|
||||
EXPECT_EQ(NextChange(fridays, Utc(2026, 9, 26)), Utc(2026, 10, 2, 18));
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, UtcOffsetMovesTheDays) {
|
||||
// October in UTC+2 starts at 22:00 UTC the day before
|
||||
const auto october = Parse(R"({"utcOffset": 120, "rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}]})");
|
||||
EXPECT_FALSE(Active(october, Utc(2026, 9, 30, 21, 59)));
|
||||
EXPECT_TRUE(Active(october, Utc(2026, 9, 30, 22)));
|
||||
EXPECT_EQ(NextChange(october, Utc(2026, 9, 26)), Utc(2026, 9, 30, 22));
|
||||
const auto west = Parse(R"({"utcOffset": -300, "rules": [{"type": "time_of_day", "from": "18:00", "to": "20:00"}]})");
|
||||
EXPECT_TRUE(Active(west, Utc(2026, 9, 26, 23)));
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, NotAndGroups) {
|
||||
// October but not on weekends (a group), or one other day
|
||||
const auto schedule = Parse(R"({"match": "any", "rules": [
|
||||
{"type": "group", "match": "all", "rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}, {"type": "weekdays", "days": ["sat", "sun"], "not": true}]},
|
||||
{"type": "dates", "from": "2026-12-25", "to": "2026-12-25"}]})");
|
||||
EXPECT_TRUE(Active(schedule, Utc(2026, 10, 2, 12))); // Friday
|
||||
EXPECT_FALSE(Active(schedule, Utc(2026, 10, 3, 12))); // Saturday
|
||||
EXPECT_TRUE(Active(schedule, Utc(2026, 12, 25, 12)));
|
||||
EXPECT_FALSE(Active(schedule, Utc(2026, 12, 26, 12)));
|
||||
}
|
||||
|
||||
// Known full moons (UTC): 2024-10-17 11:26, 2025-10-07 03:48, 2026-10-26 04:12
|
||||
TEST(ScheduleRulesTests, FullMoonTimes) {
|
||||
const std::vector<int64_t> known{ Utc(2024, 10, 17, 11, 26), Utc(2025, 10, 7, 3, 48), Utc(2026, 10, 26, 4, 12) };
|
||||
for (const auto time : known) {
|
||||
const auto found = Phases(eMoonPhase::FULL_MOON, time - 5 * 86400, time + 5 * 86400);
|
||||
ASSERT_EQ(found.size(), 1u);
|
||||
EXPECT_NEAR(static_cast<double>(found[0]), static_cast<double>(time), 5 * 60.0);
|
||||
}
|
||||
// One full moon a lunation: 12 or 13 a year
|
||||
const auto year = Phases(eMoonPhase::FULL_MOON, Utc(2026, 1, 1), Utc(2027, 1, 1));
|
||||
EXPECT_GE(year.size(), 12u);
|
||||
EXPECT_LE(year.size(), 13u);
|
||||
// New moon 2024-10-02 18:49 UTC
|
||||
const auto newMoon = Phases(eMoonPhase::NEW_MOON, Utc(2024, 9, 28), Utc(2024, 10, 6));
|
||||
ASSERT_EQ(newMoon.size(), 1u);
|
||||
EXPECT_NEAR(static_cast<double>(newMoon[0]), static_cast<double>(Utc(2024, 10, 2, 18, 49)), 5 * 60.0);
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, FullMoonRules) {
|
||||
// The day it falls on
|
||||
const auto day = Parse(R"({"rules": [{"type": "moon", "phase": "full_moon"}]})");
|
||||
EXPECT_FALSE(Active(day, Utc(2025, 10, 6, 23, 59)));
|
||||
EXPECT_TRUE(Active(day, Utc(2025, 10, 7)));
|
||||
EXPECT_TRUE(Active(day, Utc(2025, 10, 7, 23, 59)));
|
||||
EXPECT_FALSE(Active(day, Utc(2025, 10, 8)));
|
||||
EXPECT_EQ(NextChange(day, Utc(2026, 10, 20)), Utc(2026, 10, 26));
|
||||
EXPECT_EQ(NextChange(day, Utc(2026, 10, 26)), Utc(2026, 10, 27));
|
||||
|
||||
// The day in UTC-5 is the evening before in UTC: 2026-10-26 04:12 UTC is 23:12 on the 25th
|
||||
const auto west = Parse(R"({"utcOffset": -300, "rules": [{"type": "moon", "phase": "full_moon", "days": 0}]})");
|
||||
EXPECT_TRUE(Active(west, Utc(2026, 10, 25, 12)));
|
||||
EXPECT_FALSE(Active(west, Utc(2026, 10, 26, 12)));
|
||||
|
||||
// A day either side
|
||||
const auto wide = Parse(R"({"rules": [{"type": "moon", "phase": "full_moon", "days": 1}]})");
|
||||
EXPECT_TRUE(Active(wide, Utc(2024, 10, 16)));
|
||||
EXPECT_TRUE(Active(wide, Utc(2024, 10, 18, 23)));
|
||||
EXPECT_FALSE(Active(wide, Utc(2024, 10, 19)));
|
||||
|
||||
// Hours around the exact time
|
||||
const auto hours = Parse(R"({"rules": [{"type": "moon", "phase": "full_moon", "hours": 12}]})");
|
||||
EXPECT_TRUE(Active(hours, Utc(2024, 10, 17)));
|
||||
EXPECT_TRUE(Active(hours, Utc(2024, 10, 17, 23)));
|
||||
EXPECT_FALSE(Active(hours, Utc(2024, 10, 18)));
|
||||
EXPECT_FALSE(Active(hours, Utc(2024, 10, 16, 23)));
|
||||
|
||||
// Full-moon nights: the day of the full moon, 20:00 to midnight
|
||||
const auto night = Parse(R"({"match": "all", "rules": [{"type": "moon", "phase": "full_moon"}, {"type": "time_of_day", "from": "20:00", "to": "24:00"}]})");
|
||||
EXPECT_EQ(NextChange(night, Utc(2026, 10, 1)), Utc(2026, 10, 26, 20));
|
||||
const auto windows = Windows(night, Utc(2026, 1, 1), Utc(2027, 1, 1));
|
||||
EXPECT_GE(windows.size(), 12u);
|
||||
for (const auto& window : windows) EXPECT_EQ(window.end - window.start, 4 * 3600);
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, BadSchedules) {
|
||||
EXPECT_NE(ErrorOf(R"({"rules": []})").find("at least one rule"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "easter"}]})").find("Rule 1: type is one of"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "yearly", "from": "13-01", "to": "10-31"}]})").find("Rule 1"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "yearly", "from": "02-30", "to": "03-01"}]})").find("Rule 1"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "dates", "from": "2027-01-02", "to": "2026-12-20"}]})").find("after the start"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "time_of_day", "from": "18:00", "to": "18:00"}]})").find("same time"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "weekdays", "days": ["someday"]}]})").find("days are"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "moon", "phase": "blue_moon"}]})").find("phase is one of"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "moon", "phase": "full_moon", "days": 8}]})").find("days is"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"utcOffset": 900, "rules": [{"type": "weekdays", "days": ["mon"]}]})").find("utcOffset"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "group", "rules": [{"type": "group", "rules": [{"type": "group", "rules": [{"type": "group", "rules": [{"type": "weekdays", "days": ["mon"]}]}]}]}]}]})").find("deep"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "group", "match": "most", "rules": [{"type": "weekdays", "days": ["mon"]}]}]})").find("Rule 1: match"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf(R"({"rules": [{"type": "group", "rules": [{"type": "weekdays", "days": ["mon"]}, {"type": "nope"}]}]})").find("Rule 1.2"), std::string::npos);
|
||||
EXPECT_NE(ErrorOf("not json").find("JSON"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, JsonRoundTrip) {
|
||||
const std::string text = R"({"utcOffset": 60, "match": "any", "rules": [
|
||||
{"type": "YEARLY", "from": "10-01", "to": "10-31"},
|
||||
{"type": "dates", "from": "2026-12-20T18:00", "to": "2027-01-02"},
|
||||
{"type": "weekdays", "days": ["fri", "sat"], "not": true},
|
||||
{"type": "time_of_day", "from": "18:00", "to": "02:00"},
|
||||
{"type": "moon", "phase": "Full_Moon", "hours": 6},
|
||||
{"type": "group", "match": "all", "rules": [{"type": "moon", "phase": "new_moon", "days": 1}]}]})";
|
||||
const auto first = ToJson(Parse(text));
|
||||
EXPECT_EQ(first["rules"][0]["type"], "yearly");
|
||||
EXPECT_EQ(first["rules"][1]["to"], "2027-01-02");
|
||||
EXPECT_EQ(first["rules"][4]["phase"], "full_moon");
|
||||
EXPECT_EQ(first["rules"][2]["not"], true);
|
||||
EXPECT_EQ(ToJson(Parse(first.dump())), first);
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, Modes) {
|
||||
const std::string october = R"({"rules": [{"type": "yearly", "from": "10-01", "to": "10-31"}]})";
|
||||
EXPECT_TRUE(IsOn(eMode::SCHEDULED, october, Utc(2026, 10, 5)));
|
||||
EXPECT_FALSE(IsOn(eMode::SCHEDULED, october, Utc(2026, 9, 5)));
|
||||
EXPECT_TRUE(IsOn(eMode::ALWAYS_ON, october, Utc(2026, 9, 5)));
|
||||
EXPECT_FALSE(IsOn(eMode::OFF, october, Utc(2026, 10, 5)));
|
||||
EXPECT_FALSE(IsOn(eMode::SCHEDULED, "broken", Utc(2026, 10, 5)));
|
||||
}
|
||||
|
||||
TEST(ScheduleRulesTests, OnceEvents) {
|
||||
const auto start = Utc(2026, 10, 1, 18), end = Utc(2026, 10, 2, 6);
|
||||
EXPECT_FALSE(IsOn(eMode::SCHEDULED, "", start, end, start - 1));
|
||||
EXPECT_TRUE(IsOn(eMode::SCHEDULED, "", start, end, start));
|
||||
EXPECT_FALSE(IsOn(eMode::SCHEDULED, "", start, end, end));
|
||||
EXPECT_TRUE(IsOn(eMode::ALWAYS_ON, "", start, end, end + 100));
|
||||
EXPECT_FALSE(IsOn(eMode::OFF, "", start, end, start));
|
||||
|
||||
const auto windows = WindowsOf(eMode::SCHEDULED, "", start, end, Utc(2026, 10, 2), Utc(2026, 10, 3));
|
||||
ASSERT_EQ(windows.size(), 1u);
|
||||
EXPECT_EQ(windows[0].start, Utc(2026, 10, 2));
|
||||
EXPECT_EQ(windows[0].end, end);
|
||||
EXPECT_TRUE(WindowsOf(eMode::SCHEDULED, "", start, end, end, end + 86400).empty());
|
||||
EXPECT_TRUE(WindowsOf(eMode::OFF, "", start, end, start, end).empty());
|
||||
EXPECT_EQ(WindowsOf(eMode::ALWAYS_ON, "", 0, 0, start, end).size(), 1u);
|
||||
|
||||
// Recurring: the schedule decides, startsAt and endsAt are ignored
|
||||
const std::string october = R"({"rules":[{"type":"yearly","from":"10-01","to":"10-31"}]})";
|
||||
EXPECT_TRUE(IsOn(eMode::SCHEDULED, october, 0, 0, Utc(2027, 10, 5)));
|
||||
EXPECT_EQ(WindowsOf(eMode::SCHEDULED, october, 0, 0, Utc(2026, 1, 1), Utc(2028, 1, 1)).size(), 2u);
|
||||
}
|
||||
75
tests/dCommonTests/SlashCommandLevelsTests.cpp
Normal file
75
tests/dCommonTests/SlashCommandLevelsTests.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "SlashCommandLevels.h"
|
||||
|
||||
TEST(SlashCommandLevelsTests, SettingNameFromAlias) {
|
||||
EXPECT_EQ(SlashCommandLevels::SettingName("kick"), "kick");
|
||||
EXPECT_EQ(SlashCommandLevels::SettingName("removeIgnore"), "removeignore");
|
||||
EXPECT_EQ(SlashCommandLevels::SettingName("leave-zone"), "leave_zone");
|
||||
EXPECT_EQ(SlashCommandLevels::SettingName("victory!"), "victory");
|
||||
EXPECT_EQ(SlashCommandLevels::SettingName("--a--b--"), "a_b");
|
||||
EXPECT_EQ(SlashCommandLevels::ConfigName("kick"), "command_level_kick");
|
||||
EXPECT_TRUE(SlashCommandLevels::IsLevelSetting("command_level_kick"));
|
||||
EXPECT_FALSE(SlashCommandLevels::IsLevelSetting("permission_accounts_kick"));
|
||||
}
|
||||
|
||||
TEST(SlashCommandLevelsTests, StaffCommandsStayWithStaff) {
|
||||
EXPECT_EQ(SlashCommandLevels::DefaultMinLevel(0), 0);
|
||||
EXPECT_EQ(SlashCommandLevels::DefaultMinLevel(2), SlashCommandLevels::STAFF_MIN_LEVEL);
|
||||
EXPECT_EQ(SlashCommandLevels::DefaultMinLevel(9), SlashCommandLevels::STAFF_MIN_LEVEL);
|
||||
}
|
||||
|
||||
TEST(SlashCommandLevelsTests, Resolve) {
|
||||
// No value: the code's level
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, ""), 2);
|
||||
// Raised and lowered within range
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "5"), 5);
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(8, 1, false, "1"), 1);
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(0, 0, false, "9"), 9);
|
||||
// Below the floor, above GM 9 or not a number: the default
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(8, 1, false, "0"), 8);
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(8, 8, false, "6"), 8);
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "10"), 2);
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "-1"), 2);
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(2, 1, false, "abc"), 2);
|
||||
// Fixed commands ignore the setting
|
||||
EXPECT_EQ(SlashCommandLevels::Resolve(0, 0, true, "5"), 0);
|
||||
}
|
||||
|
||||
// A command paired with a dashboard permission uses the permission's level; its own value is an override
|
||||
TEST(SlashCommandLevelsTests, PairedCommandsFollowTheirPermission) {
|
||||
using SlashCommandLevels::ResolvePaired;
|
||||
// No value: the permission's level, whatever the code's default was (/mute: code GM 6, accounts_mute GM 2)
|
||||
EXPECT_EQ(ResolvePaired(6, 1, false, 2, "").level, 2);
|
||||
EXPECT_FALSE(ResolvePaired(6, 1, false, 2, "").overridden);
|
||||
// The permission changes, the command changes with it
|
||||
EXPECT_EQ(ResolvePaired(6, 1, false, 5, "").level, 5);
|
||||
// An explicit value (an older setup's command_level_mute) overrides it
|
||||
EXPECT_EQ(ResolvePaired(6, 1, false, 2, "6").level, 6);
|
||||
EXPECT_TRUE(ResolvePaired(6, 1, false, 2, "6").overridden);
|
||||
EXPECT_TRUE(ResolvePaired(6, 1, false, 2, "2").overridden); // even when it is the same level: it stays put when the permission moves
|
||||
// "permission" (set on the page over a file's value) and bad values follow the permission
|
||||
EXPECT_EQ(ResolvePaired(6, 1, false, 3, std::string(SlashCommandLevels::FOLLOW_PERMISSION)).level, 3);
|
||||
EXPECT_FALSE(ResolvePaired(6, 1, false, 3, std::string(SlashCommandLevels::FOLLOW_PERMISSION)).overridden);
|
||||
EXPECT_EQ(ResolvePaired(6, 1, false, 3, "abc").level, 3);
|
||||
EXPECT_FALSE(ResolvePaired(6, 1, false, 3, "10").overridden);
|
||||
// Floors still apply, to the permission's level and to overrides
|
||||
EXPECT_EQ(ResolvePaired(8, 8, false, 3, "").level, 8);
|
||||
EXPECT_EQ(ResolvePaired(8, 8, false, 3, "5").level, 8);
|
||||
EXPECT_FALSE(ResolvePaired(8, 8, false, 3, "5").overridden);
|
||||
EXPECT_EQ(ResolvePaired(2, 1, false, 0, "").level, 1); // a staff command never reaches players
|
||||
// Nobody above GM 9 (an unknown permission's level)
|
||||
EXPECT_EQ(ResolvePaired(2, 1, false, 10, "").level, 9);
|
||||
// Fixed commands ignore both
|
||||
EXPECT_EQ(ResolvePaired(0, 0, true, 5, "3").level, 0);
|
||||
EXPECT_FALSE(ResolvePaired(0, 0, true, 5, "3").overridden);
|
||||
}
|
||||
|
||||
TEST(SlashCommandLevelsTests, TargetRuleNames) {
|
||||
using SlashCommandLevels::eTargetRule;
|
||||
for (const auto rule : { eTargetRule::NONE, eTargetRule::TOOLS, eTargetRule::ITEMS, eTargetRule::MODERATION, eTargetRule::OTHERS }) {
|
||||
EXPECT_EQ(SlashCommandLevels::TargetRuleFromName(SlashCommandLevels::TargetRuleName(rule)), rule);
|
||||
}
|
||||
EXPECT_EQ(SlashCommandLevels::TargetRuleName(eTargetRule::NONE), "");
|
||||
EXPECT_EQ(SlashCommandLevels::TargetRuleFromName("junk"), eTargetRule::NONE);
|
||||
}
|
||||
@@ -250,3 +250,47 @@ TEST_F(LDFTests, LDFSpeedTest) {
|
||||
}
|
||||
|
||||
#endif //PERF
|
||||
|
||||
TEST_F(LDFTests, LwoNameValueLinesRoundTrip) {
|
||||
LwoNameValue config;
|
||||
config.Insert<int32_t>("modelType", 2);
|
||||
config.Insert<LWOOBJID>("userModelID", 1152921510436607007);
|
||||
config.Insert<std::u16string>("userModelName", u"My House: v2");
|
||||
config.Insert<bool>("userModelHasBhvr", true);
|
||||
|
||||
LwoNameValue parsed;
|
||||
parsed.InsertLines(config.ToLines());
|
||||
ASSERT_EQ(parsed.values.size(), 4);
|
||||
for (const auto& [key, value] : config.values) {
|
||||
const auto it = parsed.find(key);
|
||||
ASSERT_NE(it, parsed.end());
|
||||
EXPECT_EQ(it->second->GetString(), value->GetString());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(LDFTests, LwoNameValueLinesSkipsBadLines) {
|
||||
LwoNameValue parsed;
|
||||
parsed.InsertLines("\nnot an ldf line\ncount=1:5\n");
|
||||
ASSERT_EQ(parsed.values.size(), 1);
|
||||
EXPECT_NE(parsed.find(u"count"), parsed.end());
|
||||
EXPECT_TRUE(LwoNameValue{}.ToLines().empty());
|
||||
}
|
||||
|
||||
TEST_F(LDFTests, LwoNameValueLinesKeepNewlinesAndBackslashes) {
|
||||
LwoNameValue config;
|
||||
config.Insert<std::u16string>("userModelDesc", u"Line one\nLine two");
|
||||
config.Insert<std::string>("path", "scripts\\02_server\\thing.lua");
|
||||
config.Insert<std::u16string>("tricky", u"ends with a backslash\\");
|
||||
|
||||
const auto lines = config.ToLines();
|
||||
EXPECT_EQ(std::count(lines.begin(), lines.end(), '\n'), 2); // one line per value
|
||||
|
||||
LwoNameValue parsed;
|
||||
parsed.InsertLines(lines);
|
||||
ASSERT_EQ(parsed.values.size(), 3);
|
||||
for (const auto& [key, value] : config.values) {
|
||||
const auto it = parsed.find(key);
|
||||
ASSERT_NE(it, parsed.end());
|
||||
EXPECT_EQ(it->second->GetString(), value->GetString());
|
||||
}
|
||||
}
|
||||
|
||||
262
tests/dCommonTests/VanityEventsTests.cpp
Normal file
262
tests/dCommonTests/VanityEventsTests.cpp
Normal file
@@ -0,0 +1,262 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include "VanityEvents.h"
|
||||
|
||||
using namespace VanityEvents;
|
||||
|
||||
namespace {
|
||||
VanityXml::Object Npc(const std::string& name, int32_t lot = 2279) {
|
||||
VanityXml::Object object;
|
||||
object.name = name;
|
||||
object.lot = lot;
|
||||
object.locations.push_back({ 1200, 1, 2, 3 });
|
||||
return object;
|
||||
}
|
||||
|
||||
struct TestEvent {
|
||||
uint64_t id;
|
||||
std::string name;
|
||||
int32_t priority;
|
||||
std::string file;
|
||||
std::string removals;
|
||||
std::string fileSwitches;
|
||||
};
|
||||
|
||||
// A throwaway vanity folder, removed at the end of the test
|
||||
struct Folder {
|
||||
std::filesystem::path path;
|
||||
explicit Folder(const std::string& test) {
|
||||
path = std::filesystem::temp_directory_path() / ("vanity-" + test + "-" + std::to_string(::testing::UnitTest::GetInstance()->random_seed()));
|
||||
std::filesystem::remove_all(path);
|
||||
std::filesystem::create_directories(path);
|
||||
}
|
||||
~Folder() { std::filesystem::remove_all(path); }
|
||||
void Write(const std::string& name, const std::string& text) const { std::ofstream(path / name) << text; }
|
||||
};
|
||||
|
||||
std::string NpcXml(const std::string& name, int lot) {
|
||||
return "<object name=\"" + name + "\" lot=\"" + std::to_string(lot) + "\"><locations><location zone=\"1200\" x=\"1\" y=\"2\" z=\"3\" rw=\"1\" rx=\"0\" ry=\"0\" rz=\"0\"/></locations></object>";
|
||||
}
|
||||
|
||||
std::vector<std::string> Names(const std::vector<VanityXml::Object>& objects) {
|
||||
std::vector<std::string> names;
|
||||
for (const auto& object : objects) names.push_back(object.name);
|
||||
return names;
|
||||
}
|
||||
|
||||
const FileLoad* Find(const World& world, const std::string& name) {
|
||||
for (const auto& file : world.files) if (file.name == name) return &file;
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, MergeReplacesRemovesAndAdds) {
|
||||
std::vector<VanityXml::Object> base{ Npc("Bob"), Npc("Alice"), Npc(""), Npc("Bob", 4000) };
|
||||
Overlay halloween{ "Halloween", { Npc("Bob", 9999), Npc("Pumpkin", 5000), Npc("") }, { "Alice", "Nobody" } };
|
||||
const auto merged = Merge(base, { halloween });
|
||||
// Both Bobs are replaced by the event's Bob; Alice is taken out; the base's unnamed prop stays
|
||||
ASSERT_EQ(merged.objects.size(), 4u);
|
||||
EXPECT_EQ(merged.objects[0].name, "");
|
||||
EXPECT_EQ(merged.objects[1].name, "Bob");
|
||||
EXPECT_EQ(merged.objects[1].lot, 9999);
|
||||
EXPECT_EQ(merged.objects[2].name, "Pumpkin");
|
||||
EXPECT_EQ(merged.objects[3].name, "");
|
||||
EXPECT_TRUE(merged.conflicts.empty());
|
||||
ASSERT_EQ(merged.warnings.size(), 1u);
|
||||
EXPECT_NE(merged.warnings[0].find("Nobody"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, MergeOrderAndConflicts) {
|
||||
std::vector<TestEvent> events{ { 1, "Moon", 5, "", "" }, { 2, "Halloween", 0, "", "" }, { 3, "Also moon", 5, "", "" } };
|
||||
SortForMerge(events);
|
||||
ASSERT_EQ(events.size(), 3u);
|
||||
EXPECT_EQ(events[0].name, "Halloween"); // lowest priority first
|
||||
EXPECT_EQ(events[1].name, "Moon"); // then by id
|
||||
EXPECT_EQ(events[2].name, "Also moon");
|
||||
|
||||
std::vector<VanityXml::Object> base{ Npc("Bob") };
|
||||
const auto merged = Merge(base, { { "Halloween", { Npc("Bob", 100) }, {} }, { "Moon", { Npc("Bob", 200) }, {} }, { "Also moon", {}, { "Bob" } } });
|
||||
EXPECT_TRUE(merged.objects.empty()); // the last one took Bob out
|
||||
ASSERT_EQ(merged.conflicts.size(), 1u);
|
||||
EXPECT_EQ(merged.conflicts[0].npc, "Bob");
|
||||
EXPECT_EQ(merged.conflicts[0].events, (std::vector<std::string>{ "Halloween", "Moon", "Also moon" }));
|
||||
|
||||
const auto two = Merge(base, { { "Halloween", { Npc("Bob", 100) }, {} }, { "Moon", { Npc("Bob", 200) }, {} } });
|
||||
ASSERT_EQ(two.objects.size(), 1u);
|
||||
EXPECT_EQ(two.objects[0].lot, 200);
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, SplitNamesAndFileNames) {
|
||||
EXPECT_EQ(SplitNames(" Bob \r\n\nAlice\nBob\n"), (std::vector<std::string>{ "Bob", "Alice" }));
|
||||
EXPECT_TRUE(ValidFileName("halloween-2026_npcs.xml"));
|
||||
EXPECT_FALSE(ValidFileName("../root.xml"));
|
||||
EXPECT_FALSE(ValidFileName("a b.xml"));
|
||||
EXPECT_FALSE(ValidFileName(".xml"));
|
||||
EXPECT_FALSE(ValidFileName("halloween.txt"));
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, LoadFilesAndOverlays) {
|
||||
const Folder folder("load");
|
||||
folder.Write("root.xml", R"(<files><file name="a.xml" enabled="1"/><file name="off.xml" enabled="0"/><file name="root.xml" enabled="1"/><file name="../escape.xml" enabled="1"/></files>)");
|
||||
folder.Write("a.xml", "<objects>" + NpcXml("Bob", 1) + NpcXml("Alice", 2) + "</objects>");
|
||||
folder.Write("off.xml", "<objects>" + NpcXml("Hidden", 3) + "</objects>");
|
||||
folder.Write("halloween.xml", R"(<files><file name="extra.xml" enabled="1"/></files><objects>)" + NpcXml("Bob", 10) + "</objects>");
|
||||
folder.Write("extra.xml", "<objects>" + NpcXml("Pumpkin", 11) + "</objects>");
|
||||
|
||||
auto base = LoadFiles(folder.path, "root.xml");
|
||||
EXPECT_EQ(base.files, (std::vector<std::string>{ "root.xml", "a.xml" }));
|
||||
ASSERT_EQ(base.objects.size(), 2u);
|
||||
ASSERT_EQ(base.warnings.size(), 1u);
|
||||
EXPECT_NE(base.warnings[0].find("../escape.xml"), std::string::npos);
|
||||
|
||||
std::vector<TestEvent> events{ { 1, "Halloween", 0, "halloween.xml", "Alice\n" } };
|
||||
const auto world = LoadWorld(folder.path, "root.xml", events);
|
||||
EXPECT_EQ(world.fileNpcs, 2u);
|
||||
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Pumpkin", "Bob" })); // the overlay's include first, as the worlds load them
|
||||
EXPECT_EQ(world.objects[1].lot, 10);
|
||||
ASSERT_NE(Find(world, "off.xml"), nullptr);
|
||||
EXPECT_FALSE(Find(world, "off.xml")->loaded);
|
||||
EXPECT_EQ(Find(world, "off.xml")->includedBy, "root.xml");
|
||||
|
||||
std::vector<TestEvent> missing{ { 2, "Ghost", 0, "nope.xml", "" } };
|
||||
const auto withMissing = LoadWorld(folder.path, "root.xml", missing);
|
||||
EXPECT_EQ(withMissing.objects.size(), 2u);
|
||||
EXPECT_TRUE(std::any_of(withMissing.warnings.begin(), withMissing.warnings.end(), [](const auto& w) { return w.find("nope.xml not found") != std::string::npos; }));
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, FileSwitchesParse) {
|
||||
std::string error;
|
||||
const auto switches = ParseFileSwitches(R"({"halloween.xml": true, "summer.xml": false})", error);
|
||||
ASSERT_TRUE(switches) << error;
|
||||
EXPECT_EQ(*switches, (FileSwitches{ { "halloween.xml", true }, { "summer.xml", false } }));
|
||||
EXPECT_EQ(ToJson(*switches).dump(), R"({"halloween.xml":true,"summer.xml":false})");
|
||||
EXPECT_TRUE(ParseFileSwitches("", error)->empty());
|
||||
EXPECT_FALSE(ParseFileSwitches(R"({"../root.xml": true})", error));
|
||||
EXPECT_FALSE(ParseFileSwitches(R"({"a.xml": 1})", error));
|
||||
EXPECT_FALSE(ParseFileSwitches(R"(["a.xml"])", error));
|
||||
EXPECT_FALSE(ParseFileSwitches("{", error));
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, FileSwitchOnLoadsAFileNothingNames) {
|
||||
const Folder folder("switch-on");
|
||||
folder.Write("root.xml", R"(<files><file name="a.xml" enabled="1"/></files>)");
|
||||
folder.Write("a.xml", "<objects>" + NpcXml("Bob", 1) + "</objects>");
|
||||
folder.Write("zeta.xml", "<objects>" + NpcXml("Zed", 3) + "</objects>");
|
||||
folder.Write("halloween.xml", R"(<files><file name="pumpkins.xml" enabled="1"/></files><objects>)" + NpcXml("Witch", 2) + "</objects>");
|
||||
folder.Write("pumpkins.xml", "<objects>" + NpcXml("Pumpkin", 4) + "</objects>");
|
||||
|
||||
std::vector<TestEvent> events{ { 1, "Halloween", 0, "", "", R"({"zeta.xml": true, "halloween.xml": true})" } };
|
||||
const auto world = LoadWorld(folder.path, "root.xml", events);
|
||||
// After the tree, in name order, each with what it includes
|
||||
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Bob", "Pumpkin", "Witch", "Zed" }));
|
||||
EXPECT_EQ(world.fileNpcs, 4u);
|
||||
const auto* halloween = Find(world, "halloween.xml");
|
||||
ASSERT_NE(halloween, nullptr);
|
||||
EXPECT_TRUE(halloween->loaded);
|
||||
EXPECT_EQ(halloween->includedBy, "");
|
||||
EXPECT_EQ(halloween->switchedBy, "Halloween");
|
||||
EXPECT_EQ(Find(world, "pumpkins.xml")->includedBy, "halloween.xml");
|
||||
EXPECT_TRUE(world.warnings.empty());
|
||||
EXPECT_TRUE(world.fileConflicts.empty());
|
||||
|
||||
// Without the event nothing changes
|
||||
EXPECT_EQ(Names(LoadWorld(folder.path, "root.xml", std::vector<TestEvent>{}).objects), (std::vector<std::string>{ "Bob" }));
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, FileSwitchOffDropsTheFileAndItsIncludes) {
|
||||
const Folder folder("switch-off");
|
||||
folder.Write("root.xml", R"(<files><file name="summer.xml" enabled="1"/><file name="shared.xml" enabled="1"/></files>)");
|
||||
folder.Write("summer.xml", R"(<files><file name="beach.xml" enabled="1"/><file name="shared.xml" enabled="1"/></files><objects>)" + NpcXml("Surfer", 1) + "</objects>");
|
||||
folder.Write("beach.xml", "<objects>" + NpcXml("Lifeguard", 2) + "</objects>");
|
||||
folder.Write("shared.xml", "<objects>" + NpcXml("Mayor", 3) + "</objects>");
|
||||
|
||||
EXPECT_EQ(Names(LoadWorld(folder.path, "root.xml", std::vector<TestEvent>{}).objects), (std::vector<std::string>{ "Lifeguard", "Mayor", "Surfer" }));
|
||||
|
||||
std::vector<TestEvent> events{ { 1, "Halloween", 0, "", "", R"({"summer.xml": false, "unknown.xml": false})" } };
|
||||
const auto world = LoadWorld(folder.path, "root.xml", events);
|
||||
// beach.xml goes with it; shared.xml stays, root.xml includes it too
|
||||
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Mayor" }));
|
||||
const auto* summer = Find(world, "summer.xml");
|
||||
ASSERT_NE(summer, nullptr);
|
||||
EXPECT_FALSE(summer->loaded);
|
||||
EXPECT_TRUE(summer->enabled);
|
||||
EXPECT_EQ(summer->switchedBy, "Halloween");
|
||||
EXPECT_EQ(Find(world, "beach.xml"), nullptr);
|
||||
// Switching off a file nothing loads is worth a word
|
||||
ASSERT_EQ(world.warnings.size(), 1u);
|
||||
EXPECT_NE(world.warnings[0].find("unknown.xml"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, FileSwitchOnANestedInclude) {
|
||||
const Folder folder("nested");
|
||||
folder.Write("root.xml", R"(<files><file name="town.xml" enabled="1"/></files>)");
|
||||
folder.Write("town.xml", R"(<files><file name="lanterns.xml" enabled="0"/><file name="stalls.xml" enabled="1"/></files><objects>)" + NpcXml("Mayor", 1) + "</objects>");
|
||||
folder.Write("lanterns.xml", "<objects>" + NpcXml("Lantern", 2) + "</objects>");
|
||||
folder.Write("stalls.xml", "<objects>" + NpcXml("Stall", 3) + "</objects>");
|
||||
|
||||
std::vector<TestEvent> events{ { 1, "Festival", 0, "", "", R"({"lanterns.xml": true, "stalls.xml": false})" } };
|
||||
const auto world = LoadWorld(folder.path, "root.xml", events);
|
||||
// Switched on where town.xml names it, so in its place
|
||||
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Lantern", "Mayor" }));
|
||||
const auto* lanterns = Find(world, "lanterns.xml");
|
||||
ASSERT_NE(lanterns, nullptr);
|
||||
EXPECT_TRUE(lanterns->loaded);
|
||||
EXPECT_FALSE(lanterns->enabled);
|
||||
EXPECT_EQ(lanterns->includedBy, "town.xml");
|
||||
EXPECT_EQ(lanterns->switchedBy, "Festival");
|
||||
EXPECT_FALSE(Find(world, "stalls.xml")->loaded);
|
||||
EXPECT_TRUE(world.warnings.empty());
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, FileSwitchConflictsGoByPriority) {
|
||||
const Folder folder("conflict");
|
||||
folder.Write("root.xml", R"(<files><file name="summer.xml" enabled="1"/></files>)");
|
||||
folder.Write("summer.xml", "<objects>" + NpcXml("Surfer", 1) + "</objects>");
|
||||
|
||||
// Heat wave has the higher priority, so it is laid on last and keeps summer.xml on
|
||||
std::vector<TestEvent> events{ { 2, "Heat wave", 5, "", "", R"({"summer.xml": true})" }, { 1, "Halloween", 0, "", "", R"({"summer.xml": false})" } };
|
||||
const auto world = LoadWorld(folder.path, "root.xml", events);
|
||||
EXPECT_EQ(world.events, (std::vector<std::string>{ "Halloween", "Heat wave" }));
|
||||
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Surfer" }));
|
||||
ASSERT_EQ(world.fileConflicts.size(), 1u);
|
||||
EXPECT_EQ(world.fileConflicts[0].file, "summer.xml");
|
||||
EXPECT_EQ(world.fileConflicts[0].switches, (std::vector<std::pair<std::string, bool>>{ { "Halloween", false }, { "Heat wave", true } }));
|
||||
EXPECT_EQ(Find(world, "summer.xml")->switchedBy, "Heat wave");
|
||||
|
||||
events[0].priority = -1;
|
||||
const auto flipped = LoadWorld(folder.path, "root.xml", events);
|
||||
EXPECT_TRUE(flipped.objects.empty());
|
||||
EXPECT_EQ(flipped.fileConflicts[0].switches.back().first, "Halloween");
|
||||
}
|
||||
|
||||
TEST(VanityEventsTests, FileSwitchesWithOverlaysAndRemovals) {
|
||||
const Folder folder("combined");
|
||||
folder.Write("root.xml", R"(<files><file name="summer.xml" enabled="1"/><file name="town.xml" enabled="1"/></files>)");
|
||||
folder.Write("summer.xml", "<objects>" + NpcXml("Surfer", 1) + "</objects>");
|
||||
folder.Write("town.xml", "<objects>" + NpcXml("Mayor", 2) + NpcXml("Baker", 3) + "</objects>");
|
||||
folder.Write("halloween.xml", "<objects>" + NpcXml("Witch", 4) + "</objects>");
|
||||
folder.Write("halloween-look.xml", R"(<files><file name="summer.xml" enabled="1"/></files><objects>)" + NpcXml("Mayor", 40) + "</objects>");
|
||||
|
||||
// The overlay's own include is switched off too; the overlay replaces the Mayor; the Baker is taken out
|
||||
std::vector<TestEvent> events{ { 1, "Halloween", 0, "halloween-look.xml", "Baker\n", R"({"halloween.xml": true, "summer.xml": false})" } };
|
||||
const auto world = LoadWorld(folder.path, "root.xml", events);
|
||||
EXPECT_EQ(world.fileNpcs, 3u); // Mayor, Baker, Witch
|
||||
EXPECT_EQ(Names(world.objects), (std::vector<std::string>{ "Witch", "Mayor" }));
|
||||
EXPECT_EQ(world.objects[1].lot, 40);
|
||||
EXPECT_TRUE(world.warnings.empty());
|
||||
|
||||
// Taking out an NPC that only a switched-on file has works the same
|
||||
std::vector<TestEvent> more{ events[0], { 2, "No witches", 1, "", "Witch\n" } };
|
||||
const auto both = LoadWorld(folder.path, "root.xml", more);
|
||||
EXPECT_EQ(Names(both.objects), (std::vector<std::string>{ "Mayor" }));
|
||||
|
||||
// A switch that can't be read is a warning, and the rest of the event still applies
|
||||
std::vector<TestEvent> broken{ { 1, "Broken", 0, "", "Baker\n", "{nope" } };
|
||||
const auto withBroken = LoadWorld(folder.path, "root.xml", broken);
|
||||
EXPECT_EQ(Names(withBroken.objects), (std::vector<std::string>{ "Surfer", "Mayor" }));
|
||||
ASSERT_FALSE(withBroken.warnings.empty());
|
||||
EXPECT_NE(withBroken.warnings[0].find("file switches"), std::string::npos);
|
||||
}
|
||||
74
tests/dCommonTests/VanityXmlTests.cpp
Normal file
74
tests/dCommonTests/VanityXmlTests.cpp
Normal file
@@ -0,0 +1,74 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <fstream>
|
||||
|
||||
#include "VanityXml.h"
|
||||
|
||||
namespace {
|
||||
std::string ReadSource(const std::string& relative) {
|
||||
std::ifstream in(std::string(DLU_SOURCE_DIR) + "/" + relative, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(in), {});
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped files survive a read and write with nothing lost
|
||||
TEST(VanityXmlTests, ShippedFilesRoundTrip) {
|
||||
for (const auto* file : { "vanity/dev-tribute.xml", "vanity/atm.xml", "vanity/demo.xml", "vanity/root.xml" }) {
|
||||
std::string error;
|
||||
const auto first = VanityXml::Read(ReadSource(file), error);
|
||||
ASSERT_TRUE(first) << file << ": " << error;
|
||||
const auto again = VanityXml::Read(VanityXml::Write(*first), error);
|
||||
ASSERT_TRUE(again) << file << ": " << error;
|
||||
ASSERT_EQ(first->objects.size(), again->objects.size()) << file;
|
||||
ASSERT_EQ(first->files.size(), again->files.size()) << file;
|
||||
for (size_t i = 0; i < first->objects.size(); i++) {
|
||||
const auto& a = first->objects[i];
|
||||
const auto& b = again->objects[i];
|
||||
EXPECT_EQ(a.name, b.name);
|
||||
EXPECT_EQ(a.lot, b.lot);
|
||||
EXPECT_EQ(a.equipment, b.equipment);
|
||||
EXPECT_EQ(a.phrases, b.phrases);
|
||||
EXPECT_EQ(a.config, b.config);
|
||||
ASSERT_EQ(a.locations.size(), b.locations.size());
|
||||
for (size_t l = 0; l < a.locations.size(); l++) {
|
||||
EXPECT_EQ(a.locations[l].zone, b.locations[l].zone);
|
||||
EXPECT_FLOAT_EQ(a.locations[l].x, b.locations[l].x);
|
||||
EXPECT_FLOAT_EQ(a.locations[l].ry, b.locations[l].ry);
|
||||
EXPECT_EQ(a.locations[l].chance, b.locations[l].chance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(VanityXmlTests, DevTributeHasItsNpcs) {
|
||||
std::string error;
|
||||
const auto doc = VanityXml::Read(ReadSource("vanity/dev-tribute.xml"), error);
|
||||
ASSERT_TRUE(doc) << error;
|
||||
ASSERT_FALSE(doc->objects.empty());
|
||||
EXPECT_EQ(doc->objects.front().name, "Wincent - Developer");
|
||||
EXPECT_EQ(doc->objects.front().lot, 2279);
|
||||
EXPECT_EQ(doc->objects.front().equipment.size(), 4u);
|
||||
EXPECT_EQ(doc->objects.front().locations.front().zone, 1200u);
|
||||
}
|
||||
|
||||
// What the world server skips, and says so
|
||||
TEST(VanityXmlTests, SkipsIncompleteParts) {
|
||||
std::string error;
|
||||
const auto doc = VanityXml::Read(R"(<files><file name="a.xml" enabled="1"/><file name="b.xml"/><file enabled="1"/></files>)"
|
||||
R"(<objects><object name="NoLot" lot="x"><equipment>1, 2 ,x</equipment><locations><location zone="1200" x="1" y="2"/>)"
|
||||
R"(<location zone="1200" x="1" y="2" z="3" rw="1" rx="0" ry="0" rz="0" chance="0.25"/></locations></object>)"
|
||||
R"(<object name="Nowhere" lot="5"/></objects>)", error);
|
||||
ASSERT_TRUE(doc) << error;
|
||||
ASSERT_EQ(doc->files.size(), 2u);
|
||||
EXPECT_TRUE(doc->files[0].enabled);
|
||||
EXPECT_FALSE(doc->files[1].enabled);
|
||||
ASSERT_EQ(doc->objects.size(), 2u);
|
||||
EXPECT_EQ(doc->objects[0].lot, -1);
|
||||
EXPECT_EQ(doc->objects[0].equipment, (std::vector<int32_t>{ 1, 2 }));
|
||||
ASSERT_EQ(doc->objects[0].locations.size(), 1u);
|
||||
EXPECT_EQ(doc->objects[0].locations[0].chance, 0.25f);
|
||||
EXPECT_FALSE(doc->objects[0].locations[0].scale);
|
||||
EXPECT_TRUE(doc->objects[1].locations.empty());
|
||||
EXPECT_EQ(doc->warnings.size(), 2u); // the incomplete location, the object without locations
|
||||
EXPECT_FALSE(VanityXml::Read("<objects>", error));
|
||||
}
|
||||
165
tests/dCommonTests/ZoneFileTests.cpp
Normal file
165
tests/dCommonTests/ZoneFileTests.cpp
Normal file
@@ -0,0 +1,165 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
#include "LevelFile.h"
|
||||
#include "ZoneFile.h"
|
||||
|
||||
namespace {
|
||||
// Little-endian file bytes, as the client's files are laid out
|
||||
struct ZoneBytes {
|
||||
std::string data;
|
||||
template<typename T> ZoneBytes& Put(T value) { data.append(reinterpret_cast<const char*>(&value), sizeof(T)); return *this; }
|
||||
template<typename T> void At(size_t pos, T value) { std::memcpy(data.data() + pos, &value, sizeof(T)); }
|
||||
ZoneBytes& Text(const std::string& text) { Put<uint8_t>(static_cast<uint8_t>(text.size())); data += text; return *this; }
|
||||
ZoneBytes& Wide(const std::string& text) { Put<uint8_t>(static_cast<uint8_t>(text.size())); for (char c : text) Put<uint16_t>(static_cast<uint16_t>(c)); return *this; }
|
||||
ZoneBytes& Point(float x, float y, float z) { return Put(x).Put(y).Put(z); }
|
||||
void Object(uint32_t version, uint32_t lot, float x, const std::string& settings) {
|
||||
Put<int64_t>(lot * 10).Put<int32_t>(static_cast<int32_t>(lot));
|
||||
if (version >= 38) Put<int32_t>(0); // node type
|
||||
if (version >= 32) Put<uint32_t>(0); // glom ID
|
||||
Point(x, 0, 0).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f).Put(1.0f); // position, rotation, scale
|
||||
Put<uint32_t>(static_cast<uint32_t>(settings.size()));
|
||||
for (char c : settings) Put<uint16_t>(static_cast<uint16_t>(c));
|
||||
Put<uint32_t>(0);
|
||||
}
|
||||
};
|
||||
|
||||
std::string SampleZone() {
|
||||
ZoneBytes w;
|
||||
w.Put<uint32_t>(41).Put<uint32_t>(3).Put<uint32_t>(1150); // version, revision, world
|
||||
w.Point(1, 2, 3).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f); // spawn point and rotation
|
||||
w.Put<uint32_t>(1);
|
||||
w.Text("scene.lvl").Put<uint32_t>(7).Put<uint32_t>(2).Text("Global").Put<uint8_t>(1).Put<uint8_t>(2).Put<uint8_t>(3);
|
||||
w.Text("zone").Text("zone.raw").Text("Name").Text("Description");
|
||||
w.Put<uint32_t>(1);
|
||||
for (int i = 0; i < 2; i++) w.Put<uint64_t>(1).Point(0, 0, 0);
|
||||
w.Put<uint32_t>(0).Put<uint32_t>(1).Put<uint32_t>(3);
|
||||
// Movement: its config is waypoint commands (spaces dropped from the name)
|
||||
w.Put<uint32_t>(18).Wide("Patrol").Put<uint32_t>(0).Put<uint32_t>(0).Put<uint32_t>(0);
|
||||
w.Put<uint32_t>(1).Point(5, 0, 5).Put<uint32_t>(1).Wide("de lay").Wide(" 2 ");
|
||||
// Spawner: its config is LDF
|
||||
w.Put<uint32_t>(18).Wide("Spawner").Put<uint32_t>(4).Put<uint32_t>(0).Put<uint32_t>(0);
|
||||
w.Put<int32_t>(6010).Put<uint32_t>(10).Put<int32_t>(1).Put<uint32_t>(1).Put<int64_t>(123).Put<uint8_t>(1);
|
||||
w.Put<uint32_t>(1).Point(1, 1, 1).Put(1.0f).Put(0.0f).Put(0.0f).Put(0.0f).Put<uint32_t>(1).Wide("spawner_node_id").Wide("1:4");
|
||||
// Property
|
||||
w.Put<uint32_t>(8).Wide("PropertyPath").Put<uint32_t>(2).Put<uint32_t>(0).Put<uint32_t>(0);
|
||||
w.Put<int32_t>(0).Put<int32_t>(1000).Put<uint32_t>(0).Put<uint64_t>(1100);
|
||||
w.Wide("Block Yard");
|
||||
w.Put<uint32_t>(4); for (char c : std::string("Desc")) w.Put<uint16_t>(static_cast<uint16_t>(c));
|
||||
w.Put<int32_t>(0).Put<uint32_t>(0).Put(1.0f).Put<uint32_t>(0).Put<uint32_t>(0).Point(0, 0, 0).Put(128.0f);
|
||||
w.Put<uint32_t>(3).Point(0, 0, 0).Point(10, 0, 0).Point(10, 0, 10);
|
||||
return w.data;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ZoneFileTests, ReadsScenesAndPaths) {
|
||||
std::istringstream stream(SampleZone());
|
||||
ZoneFile zone;
|
||||
zone.Read(stream);
|
||||
EXPECT_FALSE(stream.fail());
|
||||
EXPECT_EQ(zone.fileFormatVersion, ZoneFile::FileFormatVersion::Latest);
|
||||
EXPECT_EQ(zone.worldID, 1150u);
|
||||
EXPECT_EQ(zone.spawnpoint, NiPoint3(1, 2, 3));
|
||||
ASSERT_EQ(zone.scenes.size(), 1u);
|
||||
EXPECT_EQ(zone.scenes[0].filename, "scene.lvl");
|
||||
EXPECT_EQ(zone.scenes[0].id, 7u);
|
||||
EXPECT_EQ(zone.scenes[0].sceneType, 2u);
|
||||
EXPECT_EQ(zone.zoneRawPath, "zone.raw");
|
||||
ASSERT_EQ(zone.sceneTransitions.size(), 1u);
|
||||
EXPECT_EQ(zone.sceneTransitions[0].points.size(), 2u);
|
||||
ASSERT_EQ(zone.paths.size(), 3u);
|
||||
|
||||
const auto& patrol = zone.paths[0].pathWaypoints.at(0);
|
||||
ASSERT_EQ(patrol.commands.size(), 1u);
|
||||
EXPECT_EQ(patrol.commands[0].command, eWaypointCommandType::DELAY);
|
||||
EXPECT_EQ(patrol.commands[0].data, "2");
|
||||
|
||||
const auto& spawner = zone.paths[1];
|
||||
EXPECT_EQ(spawner.spawner.spawnedLOT, 6010);
|
||||
EXPECT_EQ(spawner.spawner.spawnerObjID, 123);
|
||||
EXPECT_EQ(spawner.pathWaypoints.at(0).config.find(u"spawner_node_id")->second->GetValueAsString(), "4");
|
||||
|
||||
const auto& property = zone.paths[2];
|
||||
EXPECT_EQ(property.pathType, PathType::Property);
|
||||
EXPECT_EQ(property.property.displayName, "Block Yard");
|
||||
EXPECT_EQ(property.property.displayDesc, "Desc");
|
||||
EXPECT_EQ(property.property.maxBuildHeight, 128.0f);
|
||||
EXPECT_EQ(property.pathWaypoints.size(), 3u);
|
||||
}
|
||||
|
||||
TEST(ZoneFileTests, ShortFilesThrowOrFail) {
|
||||
const auto zone = SampleZone();
|
||||
for (const size_t length : { size_t{ 3 }, size_t{ 40 }, zone.size() / 2, zone.size() - 1 }) {
|
||||
std::istringstream stream(zone.substr(0, length));
|
||||
ZoneFile file;
|
||||
bool threw = false;
|
||||
try { file.Read(stream); } catch (const std::runtime_error&) { threw = true; }
|
||||
EXPECT_TRUE(threw || stream.fail()) << length;
|
||||
}
|
||||
}
|
||||
|
||||
// Chunked files lay their objects out by the file info chunk's version
|
||||
TEST(LevelFileTests, ReadsChunkedObjects) {
|
||||
ZoneBytes w;
|
||||
const auto chunk = [&w](uint32_t id) {
|
||||
const auto start = w.data.size();
|
||||
w.Put<uint32_t>(0x4B4E4843).Put<uint32_t>(id).Put<uint16_t>(1).Put<uint16_t>(0).Put<uint32_t>(0).Put<uint32_t>(0);
|
||||
w.At<uint32_t>(start + 16, static_cast<uint32_t>(w.data.size()));
|
||||
return start;
|
||||
};
|
||||
const auto end = [&w](size_t start) { w.At<uint32_t>(start + 12, static_cast<uint32_t>(w.data.size() - start)); };
|
||||
const auto info = chunk(LevelFile::FileInfo);
|
||||
w.Put<uint32_t>(41).Put<uint32_t>(9).Put<uint32_t>(0).Put<uint32_t>(0).Put<uint32_t>(0);
|
||||
end(info);
|
||||
const auto objects = chunk(LevelFile::SceneObjectData);
|
||||
w.Put<uint32_t>(2);
|
||||
w.Object(41, 4945, 10, "respawnname=0:NS_LW_Portal\nloadOnClientOnly=7:1");
|
||||
w.Object(41, 176, -4, "spawntemplate=1:6010");
|
||||
end(objects);
|
||||
|
||||
std::istringstream stream(w.data);
|
||||
LevelFile level;
|
||||
level.Read(stream);
|
||||
EXPECT_EQ(level.chunkHeaders.at(LevelFile::FileInfo).fileInfo.revision, 9u);
|
||||
ASSERT_EQ(level.objects.size(), 2u);
|
||||
EXPECT_EQ(level.objects[0].lot, 4945);
|
||||
EXPECT_EQ(level.objects[0].position.x, 10.0f);
|
||||
EXPECT_EQ(level.objects[0].settings.find(u"respawnname")->second->GetValueAsString(), "NS_LW_Portal");
|
||||
EXPECT_EQ(level.objects[1].id, 1760);
|
||||
EXPECT_EQ(level.objects[1].settings.find(u"spawntemplate")->second->GetValueAsString(), "6010");
|
||||
}
|
||||
|
||||
// Some live scenes (e.g. Nimbus Station's Gnarled Forest launch pad) are from before chunks
|
||||
TEST(LevelFileTests, ReadsFilesWithoutChunks) {
|
||||
ZoneBytes w;
|
||||
w.Put<uint16_t>(30).Put<uint16_t>(0).Put<uint8_t>(1); // version, type, important
|
||||
w.data.append(48 + 12, '\0'); // version 30 settings the world skips
|
||||
w.Put<uint32_t>(0); // no skydome
|
||||
w.Put<uint32_t>(1);
|
||||
w.Object(30, 4945, 3, "respawnname=0:NS_GF");
|
||||
|
||||
std::istringstream stream(w.data);
|
||||
LevelFile level;
|
||||
level.Read(stream);
|
||||
ASSERT_EQ(level.objects.size(), 1u);
|
||||
EXPECT_EQ(level.objects[0].position.x, 3.0f);
|
||||
EXPECT_EQ(level.objects[0].settings.find(u"respawnname")->second->GetValueAsString(), "NS_GF");
|
||||
EXPECT_EQ(level.chunkHeaders.count(LevelFile::SceneObjectData), 1u);
|
||||
}
|
||||
|
||||
TEST(LevelFileTests, DamagedFilesKeepWhatWasRead) {
|
||||
ZoneBytes w;
|
||||
w.Put<uint16_t>(30).Put<uint16_t>(0).Put<uint8_t>(1);
|
||||
w.data.append(48 + 12, '\0');
|
||||
w.Put<uint32_t>(0).Put<uint32_t>(2);
|
||||
w.Object(30, 1, 0, "a=0:b");
|
||||
w.Object(30, 2, 0, "c=0:d");
|
||||
w.data.resize(w.data.size() - 2); // cut into the last object
|
||||
|
||||
std::istringstream stream(w.data);
|
||||
LevelFile level;
|
||||
EXPECT_THROW(level.Read(stream), std::runtime_error);
|
||||
EXPECT_EQ(level.objects.size(), 1u);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "Game.h"
|
||||
|
||||
class Logger;
|
||||
class dConfig;
|
||||
namespace Game
|
||||
{
|
||||
Logger* logger;
|
||||
dConfig* config = nullptr; // Permissions.cpp reads the permission levels from it
|
||||
} // namespace Game
|
||||
|
||||
Reference in New Issue
Block a user