#include #include #include #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 ""; } std::vector Names(const std::vector& objects) { std::vector 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 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 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 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{ "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{ "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"()"); folder.Write("a.xml", "" + NpcXml("Bob", 1) + NpcXml("Alice", 2) + ""); folder.Write("off.xml", "" + NpcXml("Hidden", 3) + ""); folder.Write("halloween.xml", R"()" + NpcXml("Bob", 10) + ""); folder.Write("extra.xml", "" + NpcXml("Pumpkin", 11) + ""); auto base = LoadFiles(folder.path, "root.xml"); EXPECT_EQ(base.files, (std::vector{ "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 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{ "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 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"()"); folder.Write("a.xml", "" + NpcXml("Bob", 1) + ""); folder.Write("zeta.xml", "" + NpcXml("Zed", 3) + ""); folder.Write("halloween.xml", R"()" + NpcXml("Witch", 2) + ""); folder.Write("pumpkins.xml", "" + NpcXml("Pumpkin", 4) + ""); std::vector 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{ "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{}).objects), (std::vector{ "Bob" })); } TEST(VanityEventsTests, FileSwitchOffDropsTheFileAndItsIncludes) { const Folder folder("switch-off"); folder.Write("root.xml", R"()"); folder.Write("summer.xml", R"()" + NpcXml("Surfer", 1) + ""); folder.Write("beach.xml", "" + NpcXml("Lifeguard", 2) + ""); folder.Write("shared.xml", "" + NpcXml("Mayor", 3) + ""); EXPECT_EQ(Names(LoadWorld(folder.path, "root.xml", std::vector{}).objects), (std::vector{ "Lifeguard", "Mayor", "Surfer" })); std::vector 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{ "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"()"); folder.Write("town.xml", R"()" + NpcXml("Mayor", 1) + ""); folder.Write("lanterns.xml", "" + NpcXml("Lantern", 2) + ""); folder.Write("stalls.xml", "" + NpcXml("Stall", 3) + ""); std::vector 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{ "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"()"); folder.Write("summer.xml", "" + NpcXml("Surfer", 1) + ""); // Heat wave has the higher priority, so it is laid on last and keeps summer.xml on std::vector 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{ "Halloween", "Heat wave" })); EXPECT_EQ(Names(world.objects), (std::vector{ "Surfer" })); ASSERT_EQ(world.fileConflicts.size(), 1u); EXPECT_EQ(world.fileConflicts[0].file, "summer.xml"); EXPECT_EQ(world.fileConflicts[0].switches, (std::vector>{ { "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"()"); folder.Write("summer.xml", "" + NpcXml("Surfer", 1) + ""); folder.Write("town.xml", "" + NpcXml("Mayor", 2) + NpcXml("Baker", 3) + ""); folder.Write("halloween.xml", "" + NpcXml("Witch", 4) + ""); folder.Write("halloween-look.xml", R"()" + NpcXml("Mayor", 40) + ""); // The overlay's own include is switched off too; the overlay replaces the Mayor; the Baker is taken out std::vector 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{ "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 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{ "Mayor" })); // A switch that can't be read is a warning, and the rest of the event still applies std::vector broken{ { 1, "Broken", 0, "", "Baker\n", "{nope" } }; const auto withBroken = LoadWorld(folder.path, "root.xml", broken); EXPECT_EQ(Names(withBroken.objects), (std::vector{ "Surfer", "Mayor" })); ASSERT_FALSE(withBroken.warnings.empty()); EXPECT_NE(withBroken.warnings[0].find("file switches"), std::string::npos); }