rexglue-skate3/tests/unit/core/cvar_test.cpp
2026-05-18 22:41:13 +01:00

548 lines
18 KiB
C++

/**
* @file cvar_test.cpp
* @brief Unit tests for the cvar system
*
* @copyright Copyright (c) 2026 Tom Clay
* @license BSD 3-Clause License
*/
#include <filesystem>
#include <fstream>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <rex/cvar.h>
// Test cvars
REXCVAR_DEFINE_BOOL(test_bool_flag, false, "Test", "Test boolean flag");
REXCVAR_DEFINE_INT32(test_int32_flag, 42, "Test", "Test int32 flag");
REXCVAR_DEFINE_STRING(test_string_flag, "default", "Test", "Test string flag");
REXCVAR_DEFINE_DOUBLE(test_double_flag, 3.14, "Test", "Test double flag");
REXCVAR_DEFINE_INT32(test_ranged_flag, 5, "Test", "Test ranged flag").range(1, 10);
REXCVAR_DEFINE_STRING(test_enum_flag, "low", "Test", "Test enum-like flag")
.allowed({"low", "medium", "high"});
REXCVAR_DEFINE_STRING(test_init_only_flag, "initial", "Test", "Init-only flag")
.lifecycle(rex::cvar::Lifecycle::kInitOnly);
REXCVAR_DEFINE_BOOL(test_restart_flag, false, "Test", "Requires restart")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart);
TEST_CASE("cvar registry stores flag metadata", "[cvar]") {
auto flags = rex::cvar::ListFlags();
SECTION("Flags are registered") {
REQUIRE(flags.size() > 0);
bool found_bool = false;
bool found_int32 = false;
bool found_string = false;
for (const auto& name : flags) {
if (name == "test_bool_flag")
found_bool = true;
if (name == "test_int32_flag")
found_int32 = true;
if (name == "test_string_flag")
found_string = true;
}
CHECK(found_bool);
CHECK(found_int32);
CHECK(found_string);
}
}
TEST_CASE("cvar REXCVAR_SET and REXCVAR_GET macros", "[cvar]") {
SECTION("Set and get boolean flag") {
REXCVAR_SET(test_bool_flag, true);
CHECK(REXCVAR_GET(test_bool_flag) == true);
REXCVAR_SET(test_bool_flag, false);
CHECK(REXCVAR_GET(test_bool_flag) == false);
}
SECTION("Set and get int32 flag") {
REXCVAR_SET(test_int32_flag, 123);
CHECK(REXCVAR_GET(test_int32_flag) == 123);
}
SECTION("Set and get string flag") {
REXCVAR_SET(test_string_flag, "hello world");
CHECK(REXCVAR_GET(test_string_flag) == "hello world");
}
SECTION("Set and get double flag") {
REXCVAR_SET(test_double_flag, 2.718);
CHECK(REXCVAR_GET(test_double_flag) == Catch::Approx(2.718));
}
}
TEST_CASE("cvar SetFlagByName and GetFlagByName string API", "[cvar]") {
SECTION("Set and get by name with string conversion") {
REQUIRE(rex::cvar::SetFlagByName("test_bool_flag", "true"));
CHECK(rex::cvar::GetFlagByName("test_bool_flag") == "true");
REQUIRE(rex::cvar::SetFlagByName("test_int32_flag", "456"));
CHECK(rex::cvar::GetFlagByName("test_int32_flag") == "456");
REQUIRE(rex::cvar::SetFlagByName("test_string_flag", "test value"));
CHECK(rex::cvar::GetFlagByName("test_string_flag") == "test value");
}
SECTION("Unknown flag returns false") {
CHECK_FALSE(rex::cvar::SetFlagByName("nonexistent_flag", "value"));
CHECK(rex::cvar::GetFlagByName("nonexistent_flag").empty());
}
}
TEST_CASE("cvar boolean parsing accepts multiple formats", "[cvar]") {
// This test specifically tests the string-based SetFlagByName API
// which parses string representations into native types
SECTION("true values") {
rex::cvar::SetFlagByName("test_bool_flag", "true");
CHECK(REXCVAR_GET(test_bool_flag) == true);
rex::cvar::SetFlagByName("test_bool_flag", "1");
CHECK(REXCVAR_GET(test_bool_flag) == true);
rex::cvar::SetFlagByName("test_bool_flag", "yes");
CHECK(REXCVAR_GET(test_bool_flag) == true);
}
SECTION("false values") {
rex::cvar::SetFlagByName("test_bool_flag", "false");
CHECK(REXCVAR_GET(test_bool_flag) == false);
rex::cvar::SetFlagByName("test_bool_flag", "0");
CHECK(REXCVAR_GET(test_bool_flag) == false);
rex::cvar::SetFlagByName("test_bool_flag", "no");
CHECK(REXCVAR_GET(test_bool_flag) == false);
}
}
TEST_CASE("cvar ListFlagsByCategory", "[cvar]") {
auto test_flags = rex::cvar::ListFlagsByCategory("Test");
CHECK(test_flags.size() >= 4);
bool found = false;
for (const auto& name : test_flags) {
if (name == "test_bool_flag") {
found = true;
break;
}
}
CHECK(found);
}
TEST_CASE("cvar GetFlagInfo returns metadata", "[cvar]") {
auto* info = rex::cvar::GetFlagInfo("test_int32_flag");
REQUIRE(info != nullptr);
CHECK(info->name == "test_int32_flag");
CHECK(info->type == rex::cvar::FlagType::Int32);
CHECK(info->category == "Test");
CHECK(info->default_value == "42");
CHECK(info->lifecycle == rex::cvar::Lifecycle::kHotReload);
SECTION("Unknown flag returns nullptr") {
CHECK(rex::cvar::GetFlagInfo("nonexistent") == nullptr);
}
}
TEST_CASE("cvar TOML config loading", "[cvar]") {
auto temp_dir = std::filesystem::temp_directory_path();
auto config_path = temp_dir / "test_config.toml";
SECTION("Load flat config") {
std::ofstream file(config_path);
file << "test_bool_flag = true\n";
file << "test_int32_flag = 999\n";
file << "test_string_flag = \"from config\"\n";
file.close();
// Set initial values using REXCVAR_SET
REXCVAR_SET(test_bool_flag, false);
REXCVAR_SET(test_int32_flag, 0);
REXCVAR_SET(test_string_flag, "");
rex::cvar::LoadConfig(config_path);
CHECK(REXCVAR_GET(test_bool_flag) == true);
CHECK(REXCVAR_GET(test_int32_flag) == 999);
CHECK(REXCVAR_GET(test_string_flag) == "from config");
std::filesystem::remove(config_path);
}
SECTION("Nonexistent config is handled gracefully") {
auto missing = temp_dir / "nonexistent.toml";
rex::cvar::LoadConfig(missing);
}
}
TEST_CASE("cvar range validation", "[cvar]") {
SECTION("Value within range succeeds") {
REQUIRE(rex::cvar::SetFlagByName("test_ranged_flag", "5"));
CHECK(REXCVAR_GET(test_ranged_flag) == 5);
REQUIRE(rex::cvar::SetFlagByName("test_ranged_flag", "1"));
CHECK(REXCVAR_GET(test_ranged_flag) == 1);
REQUIRE(rex::cvar::SetFlagByName("test_ranged_flag", "10"));
CHECK(REXCVAR_GET(test_ranged_flag) == 10);
}
SECTION("Value outside range fails") {
REXCVAR_SET(test_ranged_flag, 5); // Reset to known value
CHECK_FALSE(rex::cvar::SetFlagByName("test_ranged_flag", "0"));
CHECK(REXCVAR_GET(test_ranged_flag) == 5); // Unchanged
CHECK_FALSE(rex::cvar::SetFlagByName("test_ranged_flag", "11"));
CHECK(REXCVAR_GET(test_ranged_flag) == 5); // Unchanged
}
}
TEST_CASE("cvar allowed values validation", "[cvar]") {
SECTION("Allowed value succeeds") {
REQUIRE(rex::cvar::SetFlagByName("test_enum_flag", "low"));
CHECK(REXCVAR_GET(test_enum_flag) == "low");
REQUIRE(rex::cvar::SetFlagByName("test_enum_flag", "medium"));
CHECK(REXCVAR_GET(test_enum_flag) == "medium");
REQUIRE(rex::cvar::SetFlagByName("test_enum_flag", "high"));
CHECK(REXCVAR_GET(test_enum_flag) == "high");
}
SECTION("Disallowed value fails") {
REXCVAR_SET(test_enum_flag, "low"); // Reset
CHECK_FALSE(rex::cvar::SetFlagByName("test_enum_flag", "ultra"));
CHECK(REXCVAR_GET(test_enum_flag) == "low"); // Unchanged
}
}
TEST_CASE("cvar lifecycle enforcement", "[cvar]") {
// Note: This test must run before FinalizeInit is called elsewhere,
// or use testing utilities to reset state
SECTION("InitOnly flag can be set before finalization") {
// Assuming not finalized yet in test context
if (!rex::cvar::IsFinalized()) {
REQUIRE(rex::cvar::SetFlagByName("test_init_only_flag", "modified"));
CHECK(REXCVAR_GET(test_init_only_flag) == "modified");
}
}
}
TEST_CASE("cvar restart tracking", "[cvar]") {
// Clear any existing pending flags
rex::cvar::ClearPendingRestartFlags();
SECTION("Changing RequiresRestart flag tracks it") {
auto pending = rex::cvar::GetPendingRestartFlags();
CHECK(pending.empty());
REQUIRE(rex::cvar::SetFlagByName("test_restart_flag", "true"));
pending = rex::cvar::GetPendingRestartFlags();
REQUIRE(pending.size() == 1);
CHECK(pending[0] == "test_restart_flag");
}
SECTION("ClearPendingRestartFlags clears the list") {
rex::cvar::SetFlagByName("test_restart_flag", "true");
CHECK_FALSE(rex::cvar::GetPendingRestartFlags().empty());
rex::cvar::ClearPendingRestartFlags();
CHECK(rex::cvar::GetPendingRestartFlags().empty());
}
}
TEST_CASE("cvar ListFlagsByLifecycle", "[cvar]") {
auto init_only = rex::cvar::ListFlagsByLifecycle(rex::cvar::Lifecycle::kInitOnly);
auto restart = rex::cvar::ListFlagsByLifecycle(rex::cvar::Lifecycle::kRequiresRestart);
bool found_init =
std::find(init_only.begin(), init_only.end(), "test_init_only_flag") != init_only.end();
bool found_restart =
std::find(restart.begin(), restart.end(), "test_restart_flag") != restart.end();
CHECK(found_init);
CHECK(found_restart);
}
TEST_CASE("cvar reset and diff utilities", "[cvar]") {
SECTION("HasNonDefaultValue detects changes") {
REXCVAR_SET(test_int32_flag, 42); // Reset to default
CHECK_FALSE(rex::cvar::HasNonDefaultValue("test_int32_flag"));
REXCVAR_SET(test_int32_flag, 100);
CHECK(rex::cvar::HasNonDefaultValue("test_int32_flag"));
}
SECTION("ResetToDefault restores default value") {
REXCVAR_SET(test_int32_flag, 100);
CHECK(REXCVAR_GET(test_int32_flag) == 100);
rex::cvar::ResetToDefault("test_int32_flag");
CHECK(REXCVAR_GET(test_int32_flag) == 42);
}
SECTION("ListModifiedFlags returns changed flags") {
REXCVAR_SET(test_int32_flag, 42); // Default
REXCVAR_SET(test_bool_flag, false); // Default
auto modified = rex::cvar::ListModifiedFlags();
bool found_int =
std::find(modified.begin(), modified.end(), "test_int32_flag") != modified.end();
CHECK_FALSE(found_int);
REXCVAR_SET(test_int32_flag, 999);
modified = rex::cvar::ListModifiedFlags();
found_int = std::find(modified.begin(), modified.end(), "test_int32_flag") != modified.end();
CHECK(found_int);
}
}
TEST_CASE("cvar testing utilities", "[cvar]") {
SECTION("ResetAllForTesting resets state") {
REXCVAR_SET(test_int32_flag, 999);
rex::cvar::testing::ResetAllForTesting();
CHECK(REXCVAR_GET(test_int32_flag) == 42); // Back to default
}
}
TEST_CASE("cvar TOML serialization", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
REXCVAR_SET(test_int32_flag, 999);
REXCVAR_SET(test_string_flag, "custom");
auto toml = rex::cvar::SerializeToTOML();
// Should contain modified flags
CHECK(toml.find("test_int32_flag = 999") != std::string::npos);
CHECK(toml.find("test_string_flag = \"custom\"") != std::string::npos);
// Should not contain flags at default
CHECK(toml.find("test_bool_flag") == std::string::npos);
}
TEST_CASE("cvar metadata integration test", "[cvar][integration]") {
rex::cvar::testing::ResetAllForTesting();
SECTION("Full metadata workflow") {
// 1. Verify metadata is queryable
auto* info = rex::cvar::GetFlagInfo("test_ranged_flag");
REQUIRE(info != nullptr);
CHECK(info->constraints.min.value_or(0) == 1);
CHECK(info->constraints.max.value_or(0) == 10);
// 2. Verify validation works (set to valid non-default value, then invalid)
CHECK(rex::cvar::SetFlagByName("test_ranged_flag", "7"));
CHECK_FALSE(rex::cvar::SetFlagByName("test_ranged_flag", "100"));
// 3. Verify change tracking (value 7 is different from default 5)
CHECK(rex::cvar::HasNonDefaultValue("test_ranged_flag"));
// 4. Verify reset works
rex::cvar::ResetToDefault("test_ranged_flag");
CHECK_FALSE(rex::cvar::HasNonDefaultValue("test_ranged_flag"));
}
}
// Additional test cvars for extended coverage
REXCVAR_DEFINE_INT64(test_int64_flag, 1000000000LL, "Test", "Test int64 flag");
REXCVAR_DEFINE_UINT32(test_uint32_flag, 42u, "Test", "Test uint32 flag");
REXCVAR_DEFINE_UINT64(test_uint64_flag, 999999999999ULL, "Test", "Test uint64 flag");
REXCVAR_DEFINE_STRING(test_validated_flag, "valid", "Test", "Custom validated flag")
.validator([](std::string_view v) { return v.size() >= 3; });
REXCVAR_DEFINE_BOOL(test_debug_flag, false, "Test", "Debug-only flag").debug_only();
REXCVAR_DEFINE_STRING(test_category_flag, "value", "TestCategory", "For category filter test");
TEST_CASE("cvar INT64/UINT32/UINT64 types", "[cvar]") {
SECTION("INT64 get/set") {
REXCVAR_SET(test_int64_flag, 9876543210LL);
CHECK(REXCVAR_GET(test_int64_flag) == 9876543210LL);
REQUIRE(rex::cvar::SetFlagByName("test_int64_flag", "-123456789"));
CHECK(REXCVAR_GET(test_int64_flag) == -123456789LL);
}
SECTION("UINT32 get/set") {
REXCVAR_SET(test_uint32_flag, 4000000000u);
CHECK(REXCVAR_GET(test_uint32_flag) == 4000000000u);
REQUIRE(rex::cvar::SetFlagByName("test_uint32_flag", "123"));
CHECK(REXCVAR_GET(test_uint32_flag) == 123u);
}
SECTION("UINT64 get/set") {
REXCVAR_SET(test_uint64_flag, 18446744073709551000ULL);
CHECK(REXCVAR_GET(test_uint64_flag) == 18446744073709551000ULL);
REQUIRE(rex::cvar::SetFlagByName("test_uint64_flag", "999"));
CHECK(REXCVAR_GET(test_uint64_flag) == 999ULL);
}
}
TEST_CASE("cvar custom validator", "[cvar]") {
SECTION("Valid value passes") {
REQUIRE(rex::cvar::SetFlagByName("test_validated_flag", "abc"));
CHECK(REXCVAR_GET(test_validated_flag) == "abc");
REQUIRE(rex::cvar::SetFlagByName("test_validated_flag", "long_string"));
CHECK(REXCVAR_GET(test_validated_flag) == "long_string");
}
SECTION("Invalid value fails") {
REXCVAR_SET(test_validated_flag, "valid");
// Too short (less than 3 chars)
CHECK_FALSE(rex::cvar::SetFlagByName("test_validated_flag", "ab"));
CHECK(REXCVAR_GET(test_validated_flag) == "valid"); // Unchanged
CHECK_FALSE(rex::cvar::SetFlagByName("test_validated_flag", "x"));
CHECK(REXCVAR_GET(test_validated_flag) == "valid"); // Unchanged
}
}
TEST_CASE("cvar debug_only flag", "[cvar]") {
auto* info = rex::cvar::GetFlagInfo("test_debug_flag");
REQUIRE(info != nullptr);
CHECK(info->is_debug_only == true);
// Non-debug flag should have is_debug_only = false
auto* non_debug = rex::cvar::GetFlagInfo("test_bool_flag");
REQUIRE(non_debug != nullptr);
CHECK(non_debug->is_debug_only == false);
}
TEST_CASE("cvar ScopedLifecycleOverride", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
// Finalize to lock init-only flags
rex::cvar::FinalizeInit();
CHECK(rex::cvar::IsFinalized());
SECTION("Init-only blocked after finalization") {
CHECK_FALSE(rex::cvar::SetFlagByName("test_init_only_flag", "blocked"));
}
SECTION("ScopedLifecycleOverride allows modification") {
{
rex::cvar::testing::ScopedLifecycleOverride override;
REQUIRE(rex::cvar::SetFlagByName("test_init_only_flag", "overridden"));
CHECK(REXCVAR_GET(test_init_only_flag) == "overridden");
}
// After scope, should be blocked again
CHECK_FALSE(rex::cvar::SetFlagByName("test_init_only_flag", "blocked_again"));
}
rex::cvar::testing::ResetAllForTesting();
}
TEST_CASE("cvar ResetAllToDefaults", "[cvar]") {
// Modify several flags
REXCVAR_SET(test_bool_flag, true);
REXCVAR_SET(test_int32_flag, 999);
REXCVAR_SET(test_string_flag, "modified");
CHECK(REXCVAR_GET(test_bool_flag) == true);
CHECK(REXCVAR_GET(test_int32_flag) == 999);
CHECK(REXCVAR_GET(test_string_flag) == "modified");
rex::cvar::ResetAllToDefaults();
CHECK(REXCVAR_GET(test_bool_flag) == false);
CHECK(REXCVAR_GET(test_int32_flag) == 42);
CHECK(REXCVAR_GET(test_string_flag) == "default");
}
TEST_CASE("cvar SerializeToTOML with category filter", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
// Modify flags in different categories
REXCVAR_SET(test_int32_flag, 123); // Category: Test
REXCVAR_SET(test_category_flag, "changed"); // Category: TestCategory
SECTION("Filter by category returns only that category") {
auto test_toml = rex::cvar::SerializeToTOML("Test");
auto category_toml = rex::cvar::SerializeToTOML("TestCategory");
CHECK(test_toml.find("test_int32_flag") != std::string::npos);
CHECK(test_toml.find("test_category_flag") == std::string::npos);
CHECK(category_toml.find("test_category_flag") != std::string::npos);
CHECK(category_toml.find("test_int32_flag") == std::string::npos);
}
}
TEST_CASE("cvar SaveConfig", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
auto temp_dir = std::filesystem::temp_directory_path();
auto save_path = temp_dir / "test_save_config.toml";
// Clean up any existing file
std::filesystem::remove(save_path);
SECTION("SaveConfig writes modified flags to file") {
REXCVAR_SET(test_int32_flag, 777);
REXCVAR_SET(test_string_flag, "saved_value");
rex::cvar::SaveConfig(save_path);
// Verify file exists and contains expected content
REQUIRE(std::filesystem::exists(save_path));
std::string content;
{
std::ifstream file(save_path);
content =
std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
CHECK(content.find("test_int32_flag = 777") != std::string::npos);
CHECK(content.find("test_string_flag = \"saved_value\"") != std::string::npos);
std::filesystem::remove(save_path);
}
SECTION("SaveConfig with no modifications creates no file or empty") {
rex::cvar::testing::ResetAllForTesting();
rex::cvar::SaveConfig(save_path);
// Either file doesn't exist or is minimal (just header comment)
}
}
TEST_CASE("cvar ApplyEnvironment", "[cvar]") {
rex::cvar::testing::ResetAllForTesting();
// Note: This test modifies the environment, which may affect other tests
// In practice, environment application happens once at startup
SECTION("Environment variables are applied with REX_ prefix") {
// Set environment variable
#ifdef _WIN32
_putenv_s("REX_TEST_INT32_FLAG", "12345");
#else
setenv("REX_TEST_INT32_FLAG", "12345", 1);
#endif
rex::cvar::ApplyEnvironment();
CHECK(REXCVAR_GET(test_int32_flag) == 12345);
// Clean up
#ifdef _WIN32
_putenv_s("REX_TEST_INT32_FLAG", "");
#else
unsetenv("REX_TEST_INT32_FLAG");
#endif
}
}