#include #include "fgc/Paths.h" #include #include #include #include #include using namespace fgc; TEST_CASE("expandUser expands ~ and environment variables") { setenv("HOME", "/home/tester", 1); setenv("FOO", "bar", 1); CHECK(paths::expandUser("~/x") == "/home/tester/x"); CHECK(paths::expandUser("~") == "/home/tester"); CHECK(paths::expandUser("$FOO/y") == "bar/y"); CHECK(paths::expandUser("${FOO}z") == "barz"); CHECK(paths::expandUser("/abs/path") == "/abs/path"); CHECK(paths::expandUser("") == ""); } TEST_CASE("configSearchPaths honours the CLI argument first") { auto p = paths::configSearchPaths("/explicit/config.ini"); REQUIRE(!p.empty()); CHECK(p.front() == "/explicit/config.ini"); } TEST_CASE("the tower folder is searched before the old places") { unsetenv("FGC_CONFIG"); setenv("XDG_CONFIG_HOME", "/home/tester/.config", 1); auto p = paths::configSearchPaths(""); unsetenv("XDG_CONFIG_HOME"); // A copy left in the project folder - where a deploy can reach it - must // never win over the tower's own. REQUIRE(p.size() >= 2); CHECK(p[0] == "/home/tester/.config/fire_gimbal_control/config.ini"); CHECK(p[1] == "config.ini"); } TEST_CASE("towerConfigDir falls back to ~/.config") { unsetenv("XDG_CONFIG_HOME"); setenv("HOME", "/home/tester", 1); CHECK(paths::towerConfigDir() == "/home/tester/.config/fire_gimbal_control"); } TEST_CASE("an explicit config is never reported as being in the old place") { CHECK_FALSE(paths::configInOldPlace("/somewhere/config.ini", "/somewhere/config.ini")); } TEST_CASE("hardwareSearchPaths looks in the documented order") { unsetenv("FGC_HARDWARE"); auto p = paths::hardwareSearchPaths("", "/opt/fgc/config.ini"); auto at = [&](const std::string& s) { for (size_t i = 0; i < p.size(); ++i) if (p[i] == s) return static_cast(i); return -1; }; // /etc is the normal answer on a tower - root-owned, per-device, and OUTSIDE // the deploy tree - so it is searched before a checkout-local file. REQUIRE(at("/etc/fire_gimbal_control/hardware.ini") >= 0); REQUIRE(at("/opt/fgc/hardware.ini") >= 0); CHECK(at("/etc/fire_gimbal_control/hardware.ini") < at("/opt/fgc/hardware.ini")); SUBCASE("an explicit argument outranks everything") { auto q = paths::hardwareSearchPaths("/tmp/mine.ini", "/opt/fgc/config.ini"); CHECK(q.front() == "/tmp/mine.ini"); } SUBCASE("the environment outranks /etc but not the argument") { setenv("FGC_HARDWARE", "/tmp/env.ini", 1); auto q = paths::hardwareSearchPaths("/tmp/cli.ini", "/opt/fgc/config.ini"); REQUIRE(q.size() >= 2); CHECK(q[0] == "/tmp/cli.ini"); CHECK(q[1] == "/tmp/env.ini"); unsetenv("FGC_HARDWARE"); } SUBCASE("with no config path there is simply no sibling candidate") { auto q = paths::hardwareSearchPaths("", ""); for (const auto& s : q) CHECK(s.find("/opt/fgc/") == std::string::npos); } } TEST_CASE("resolveHardwarePath returns nothing when there is no hardware.ini") { // Not an error: every existing tower is in this state until it is migrated, // and the program must go on reading everything from config.ini. unsetenv("FGC_HARDWARE"); CHECK_FALSE(paths::resolveHardwarePath("/nonexistent/nope.ini", "").has_value()); } // --- report pruning: the log dir must not grow without bound ---------------- namespace { // Point defaultLogDir() at a scratch dir, restoring the environment afterwards // even if a REQUIRE aborts the body. struct ScopedDataHome { std::string old_; bool had_ = false; std::filesystem::path dir; explicit ScopedDataHome(const char* tag) { if (const char* p = ::getenv("XDG_DATA_HOME")) { old_ = p; had_ = true; } dir = std::filesystem::temp_directory_path() / ("fgc-prune-" + std::string(tag) + "-" + std::to_string(::getpid())); std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); ::setenv("XDG_DATA_HOME", dir.c_str(), 1); } ~ScopedDataHome() { if (had_) ::setenv("XDG_DATA_HOME", old_.c_str(), 1); else ::unsetenv("XDG_DATA_HOME"); std::error_code ec; std::filesystem::remove_all(dir, ec); } int countMatching(const std::string& glob_prefix, const std::string& ext) const { int n = 0; std::error_code ec; for (const auto& e : std::filesystem::directory_iterator(fgc::paths::defaultLogDir(), ec)) { const std::string name = e.path().filename().string(); if (name.rfind(glob_prefix, 0) == 0 && e.path().extension() == ext) ++n; } return n; } }; } // namespace TEST_CASE("writing a report prunes older ones of the same kind") { ScopedDataHome home("keep"); for (int i = 0; i < 12; ++i) fgc::paths::writeLogFile("test_2026092" + std::to_string(i / 10) + "-00000" + std::to_string(i % 10) + ".log", "body", /*keep=*/5); CHECK(home.countMatching("test_", ".log") == 5); } TEST_CASE("only files this program generated are eligible for pruning") { // [Test] baseline_file is CONFIGURABLE and may live in this directory. It is // the reference every self-test compares against, so pruning it would // silently discard the comparison. // // The default `test_baseline.txt` survives a prefix-only match by accident - // 'b' sorts above the digits of a year, so it never lands in the delete // range. A baseline named `test_mybaseline.log` does not have that luck, and // it is the case a prefix-only match would destroy. Hence: the name must // look exactly like timestampedLogName()'s output. ScopedDataHome home("baseline"); std::filesystem::create_directories(fgc::paths::defaultLogDir()); const auto dir = std::filesystem::path(fgc::paths::defaultLogDir()); const auto dflt = dir / "test_baseline.txt"; const auto custom = dir / "test_mybaseline.log"; // sorts BELOW the reports { std::ofstream(dflt) << "pinned"; } { std::ofstream(custom) << "pinned"; } for (int i = 0; i < 10; ++i) fgc::paths::writeLogFile("test_20260922-00000" + std::to_string(i) + ".log", "b", /*keep=*/2); CHECK(std::filesystem::exists(dflt)); CHECK(std::filesystem::exists(custom)); // Exactly the generated reports were pruned, and neither baseline consumed // one of the retained slots. CHECK(home.countMatching("test_2026", ".log") == 2); } TEST_CASE("a name that is not timestampedLogName's shape is left alone") { ScopedDataHome home("shape"); std::filesystem::create_directories(fgc::paths::defaultLogDir()); const auto dir = std::filesystem::path(fgc::paths::defaultLogDir()); for (const char* odd : {"test_notes.log", "test_2026-09-22.log", "test_20260922-0000.log"}) { std::ofstream(dir / odd) << "x"; } for (int i = 0; i < 6; ++i) fgc::paths::writeLogFile("test_20260922-00000" + std::to_string(i) + ".log", "b", 1); for (const char* odd : {"test_notes.log", "test_2026-09-22.log", "test_20260922-0000.log"}) CHECK(std::filesystem::exists(dir / odd)); // Narrow enough to match ONLY the generated reports: "test_20260922-0000.log" // is one of the planted oddities and shares a shorter prefix with them. CHECK(home.countMatching("test_20260922-00000", ".log") == 1); } TEST_CASE("pruning one kind leaves the others alone") { ScopedDataHome home("kinds"); for (int i = 0; i < 4; ++i) fgc::paths::writeLogFile("diag_20260922-00000" + std::to_string(i) + ".log", "d", 10); for (int i = 0; i < 6; ++i) fgc::paths::writeLogFile("test_20260922-00000" + std::to_string(i) + ".log", "t", 2); CHECK(home.countMatching("test_", ".log") == 2); // pruned CHECK(home.countMatching("diag_", ".log") == 4); // untouched } TEST_CASE("the newest report survives, and it is the one just written") { ScopedDataHome home("newest"); std::string last; for (int i = 0; i < 8; ++i) last = fgc::paths::writeLogFile("test_20260922-00000" + std::to_string(i) + ".log", "body-" + std::to_string(i), /*keep=*/3); REQUIRE_FALSE(last.empty()); CHECK(std::filesystem::exists(last)); } TEST_CASE("keep = 0 opts out, preserving the old unbounded behaviour") { ScopedDataHome home("optout"); for (int i = 0; i < 7; ++i) fgc::paths::writeLogFile("test_20260922-00000" + std::to_string(i) + ".log", "b", /*keep=*/0); CHECK(home.countMatching("test_", ".log") == 7); }