fwt_software/tests/test_paths.cpp

213 lines
8.7 KiB
C++

#include <doctest/doctest.h>
#include "fgc/Paths.h"
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
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<int>(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);
}