Files
DarkflameServer/tests/dWebTests/SettingsCatalogTests.cpp
Aaron Kimbrell 7f0c0c23d0 chore(settings): every catalog setting in the shipped .ini files; forget settings taken out of a file
- resources/*.ini list the 73 settings the catalog knew but the files left
  out (dashboard AI helper, backups, metrics, public status, strikes,
  property rent and reputation, chat log, contraband, logins...), with
  their title, description and default. The test
  ShippedFilesListEveryCatalogSetting keeps it that way; with
  DLU_WRITE_INI_TEMPLATES=1 it writes the missing ones.
- ConfigSync forgets a setting row when its key has left a file the
  server read: from the file, no value set on the dashboard, not a
  permission level. Before, rows of removed keys stayed forever.
- Docs: where setting rows come from and when they go, the templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:31:23 -05:00

235 lines
11 KiB
C++

#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <regex>
#include <cstdlib>
#include <map>
#include <set>
#include <sstream>
#include "SettingsCatalog.h"
using SettingsCatalog::eType;
namespace {
std::optional<std::string> 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<br/>\nb"), "a<br/>\nb");
}
TEST(SettingsCatalogTests, EntriesAreWellFormed) {
std::set<std::pair<std::string, std::string>> seen;
std::set<std::string> 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<std::string> 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<SettingsCatalog::Condition>& 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<std::string> 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<std::string> 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<std::string, std::vector<std::pair<std::string, std::vector<const SettingsCatalog::Setting*>>>> 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<char>(in)), std::istreambuf_iterator<char>());
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";
}
}
}
}