#include #include #include #include #include #include #include #include #include "SettingsCatalog.h" using SettingsCatalog::eType; namespace { std::optional Normalize(const std::string& file, const std::string& key, const std::string& value) { const auto* setting = SettingsCatalog::Find(file, key); EXPECT_NE(setting, nullptr) << key; std::string error; return setting ? SettingsCatalog::Normalize(*setting, value, error) : std::nullopt; } bool InCatalog(const std::string& key) { for (const auto& setting : SettingsCatalog::All()) { if (setting.key == key) return true; } return false; } } TEST(SettingsCatalogTests, BooleansBecomeOneOrZero) { EXPECT_EQ(Normalize("worldconfig.ini", "solo_racing", "true"), "1"); EXPECT_EQ(Normalize("worldconfig.ini", "solo_racing", " YES "), "1"); EXPECT_EQ(Normalize("worldconfig.ini", "solo_racing", "off"), "0"); EXPECT_EQ(Normalize("worldconfig.ini", "solo_racing", "0"), "0"); EXPECT_FALSE(Normalize("worldconfig.ini", "solo_racing", "maybe")); // Empty means unset, so the default applies EXPECT_EQ(Normalize("worldconfig.ini", "solo_racing", ""), ""); } TEST(SettingsCatalogTests, NumbersAreRangeChecked) { EXPECT_EQ(Normalize("sharedconfig.ini", "maximum_mtu_size", " 1400 "), "1400"); EXPECT_FALSE(Normalize("sharedconfig.ini", "maximum_mtu_size", "2000")); EXPECT_FALSE(Normalize("sharedconfig.ini", "maximum_mtu_size", "12.5")); EXPECT_FALSE(Normalize("masterconfig.ini", "master_server_port", "70000")); EXPECT_EQ(Normalize("worldconfig.ini", "hardcore_coin_keep", "0.25"), "0.25"); EXPECT_FALSE(Normalize("worldconfig.ini", "hardcore_coin_keep", "2")); EXPECT_FALSE(Normalize("worldconfig.ini", "hardcore_coin_keep", "nan")); } TEST(SettingsCatalogTests, ListsAreCleanedUp) { EXPECT_EQ(Normalize("worldconfig.ini", "hardcore_excluded_item_drops", " 6086 , 7044,,"), "6086,7044"); EXPECT_FALSE(Normalize("worldconfig.ini", "hardcore_excluded_item_drops", "6086,abc")); EXPECT_EQ(Normalize("authconfig.ini", "rewardcodes", "4, 30"), "4,30"); } TEST(SettingsCatalogTests, ChoicesMatchIgnoringCase) { EXPECT_EQ(Normalize("dashboardconfig.ini", "smtp_security", "STARTTLS"), "starttls"); EXPECT_FALSE(Normalize("dashboardconfig.ini", "smtp_security", "ssl")); } TEST(SettingsCatalogTests, TextIsOneLineUnlessMultiline) { EXPECT_FALSE(Normalize("worldconfig.ini", "source", "a\nb")); EXPECT_EQ(Normalize("worldconfig.ini", "help_0_description", "a
\nb"), "a
\nb"); } TEST(SettingsCatalogTests, EntriesAreWellFormed) { std::set> seen; std::set files; for (const auto& [file, name] : SettingsCatalog::Files()) files.insert(file); for (const auto& setting : SettingsCatalog::All()) { EXPECT_TRUE(seen.insert({ setting.file, setting.key }).second) << "duplicate " << setting.key; EXPECT_TRUE(files.contains(setting.file)) << setting.key; EXPECT_FALSE(setting.title.empty()) << setting.key; EXPECT_FALSE(setting.section.empty()) << setting.key; // The default must pass its own validation std::string error; EXPECT_EQ(SettingsCatalog::Normalize(setting, setting.defaultValue, error), setting.defaultValue) << setting.key << ": " << error; if (setting.type == eType::CHOICE) EXPECT_FALSE(setting.choices.empty()) << setting.key; if (!setting.choiceLabels.empty()) EXPECT_EQ(setting.choiceLabels.size(), setting.choices.size()) << setting.key << ": one label per choice"; } } // The page groups settings by category and section, and hides ones whose condition isn't met TEST(SettingsCatalogTests, SectionsAndConditionsHoldTogether) { std::set categories, sections; for (const auto& category : SettingsCatalog::Categories()) EXPECT_TRUE(categories.insert(category.id).second) << "duplicate category " << category.id; for (const auto& section : SettingsCatalog::Sections()) { EXPECT_TRUE(sections.insert(section.name).second) << "section names must be unique: " << section.name; EXPECT_TRUE(categories.contains(section.category)) << section.name; } const auto checkCondition = [](const std::optional& condition, const std::string& what) { if (!condition) return; const auto* controller = SettingsCatalog::Find(condition->file, condition->key); ASSERT_NE(controller, nullptr) << what << " depends on " << condition->key << ", which isn't in the catalog"; EXPECT_FALSE(condition->values.empty()) << what; for (const auto& value : condition->values) { std::string error; EXPECT_EQ(SettingsCatalog::Normalize(*controller, value, error), value) << what << ": " << value << " isn't a value of " << condition->key; } }; for (const auto& section : SettingsCatalog::Sections()) { checkCondition(section.condition, "section " + section.name); // A section can't be switched on by one of its own settings: it would hide the switch if (section.condition) { const auto* controller = SettingsCatalog::Find(section.condition->file, section.condition->key); if (controller) EXPECT_NE(controller->section, section.name) << section.name; } } std::set used; for (const auto& setting : SettingsCatalog::All()) { EXPECT_TRUE(sections.contains(setting.section)) << setting.key; used.insert(setting.section); checkCondition(setting.condition, setting.key); // Units go next to the input, not in the title static const std::regex unitInTitle(R"(\((ms|days|seconds|%|bytes)\))"); EXPECT_FALSE(std::regex_search(setting.title, unitInTitle)) << setting.key << ": put the unit in .unit"; if (setting.type == eType::INT_LIST) EXPECT_TRUE(setting.defaultValue.empty() || setting.defaultValue.find(' ') == std::string::npos) << setting.key; } for (const auto& section : sections) EXPECT_TRUE(used.contains(section)) << "empty section " << section; } TEST(SettingsCatalogTests, FormatsAreChecked) { EXPECT_EQ(Normalize("dashboardconfig.ini", "dashboard_url", "https://dash.example.com"), "https://dash.example.com"); EXPECT_FALSE(Normalize("dashboardconfig.ini", "dashboard_url", "dash.example.com")); EXPECT_EQ(Normalize("dashboardconfig.ini", "smtp_from_address", "noreply@example.com"), "noreply@example.com"); EXPECT_FALSE(Normalize("dashboardconfig.ini", "smtp_from_address", "noreply example.com")); EXPECT_FALSE(Normalize("sharedconfig.ini", "external_ip", "http://1.2.3.4/")); EXPECT_EQ(Normalize("sharedconfig.ini", "external_ip", "play.example.com"), "play.example.com"); } // Every setting in the shipped config files is described in the catalog TEST(SettingsCatalogTests, CoversEveryShippedSetting) { const std::filesystem::path resources = std::filesystem::path(DLU_SOURCE_DIR) / "resources"; size_t checked = 0; for (const auto& entry : std::filesystem::directory_iterator(resources)) { if (entry.path().extension() != ".ini") continue; const auto file = entry.path().filename().string(); std::ifstream in(entry.path()); std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] == '#' || line.find('=') == std::string::npos) continue; const auto key = line.substr(0, line.find('=')); EXPECT_NE(SettingsCatalog::Find(file, key), nullptr) << file << " " << key << " is not in SettingsCatalog.cpp"; checked++; } } EXPECT_GT(checked, 100u); } // Every setting the servers read by name is described too, including ones no .ini mentions TEST(SettingsCatalogTests, CoversEverySettingTheCodeReads) { const std::regex read(R"re((?:GetValue(?:<[^>]+>)?|ConfigFlag|Setting|ParseDelimSetting<[^>]+>\([^,]+,)\s*\(?\s*"([a-z0-9_]+)")re"); std::set missing; size_t found = 0; for (const auto& entry : std::filesystem::recursive_directory_iterator(DLU_SOURCE_DIR)) { const auto relative = std::filesystem::relative(entry.path(), DLU_SOURCE_DIR).string(); if (!relative.starts_with('d') || relative.starts_with("docs") || !entry.is_regular_file()) continue; const auto extension = entry.path().extension(); if (extension != ".cpp" && extension != ".h") continue; std::ifstream in(entry.path()); std::string line; while (std::getline(in, line)) { const auto code = line.substr(0, line.find("//")); if (code.find("GetValue") == std::string::npos && code.find("ConfigFlag") == std::string::npos && code.find("Setting") == std::string::npos) continue; for (std::sregex_iterator it(code.begin(), code.end(), read), end; it != end; ++it) { const auto key = (*it)[1].str(); found++; if (!InCatalog(key)) missing.insert(key + " (" + relative + ")"); } } } EXPECT_GT(found, 100u); for (const auto& key : missing) ADD_FAILURE() << key << " is read by the code but not in SettingsCatalog.cpp"; } // Every setting in the catalog is in its shipped .ini (as `key=` or named in a comment), so a server's files list what // it reads. With DLU_WRITE_INI_TEMPLATES=1 the missing ones are added to resources/*.ini instead, under their section, // with their description and default. TEST(SettingsCatalogTests, ShippedFilesListEveryCatalogSetting) { const std::filesystem::path resources = std::filesystem::path(DLU_SOURCE_DIR) / "resources"; const bool write = std::getenv("DLU_WRITE_INI_TEMPLATES") && std::string(std::getenv("DLU_WRITE_INI_TEMPLATES")) == "1"; const auto mentions = [](const std::string& text, const std::string& key) { const std::regex named("(^|[\\s#,:(])" + key + "(=|[\\s,:.)]|$)", std::regex::multiline); return std::regex_search(text, named); }; // file -> section -> the settings missing from it, in catalog order std::map>>> missing; for (const auto& setting : SettingsCatalog::All()) { if (setting.unused || setting.key.ends_with('_')) continue; // old names; numbered families (event_1, help_1...) std::ifstream in(resources / setting.file); const std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); if (mentions(text, setting.key)) continue; auto& sections = missing[setting.file]; auto it = std::find_if(sections.begin(), sections.end(), [&](const auto& s) { return s.first == setting.section; }); if (it == sections.end()) it = sections.insert(sections.end(), { setting.section, {} }); it->second.push_back(&setting); } if (!write) { for (const auto& [file, sections] : missing) { for (const auto& [section, settings] : sections) { for (const auto* setting : settings) ADD_FAILURE() << file << " " << setting->key << " is in SettingsCatalog.cpp but not in resources/" << file << " (run with DLU_WRITE_INI_TEMPLATES=1 to add it)"; } } return; } // A comment wrapped to 120 columns const auto comment = [](const std::string& text) { std::string out, line = "#"; std::istringstream words(text); std::string word; while (words >> word) { if (line.size() + 1 + word.size() > 120) { out += line + "\n"; line = "#"; } line += " " + word; } return line == "#" ? out : out + line + "\n"; }; for (const auto& [file, sections] : missing) { std::ofstream out(resources / file, std::ios::app); for (const auto& [section, settings] : sections) { out << "\n# ---- " << section << " ----\n"; for (const auto* setting : settings) { std::string text = setting->title; if (!setting->description.empty()) text += ": " + setting->description; if (!setting->unit.empty()) text += " (" + setting->unit + ")"; if (setting->restart) text += " Read when the server starts."; out << comment(text) << setting->key << "=" << (setting->type == SettingsCatalog::eType::SECRET ? "" : setting->defaultValue) << "\n"; } } } }