#include "doctest/doctest.h" #include "fgc/HardwarePresence.h" #include using namespace fgc; namespace { // A 2-axis Delta tower with both sensors and one camera, as hardware.ini // declares it. HardwareConfig declaredTower() { HardwareConfig h; h.declared = true; h.source_path = "/etc/fire_gimbal_control/hardware.ini"; h.axes = {"yaw", "pitch"}; h.imu = "mti-630"; h.env = "sht41"; h.cameras = {"rgb"}; return h; } // The same tower, working. FoundHardware foundTower() { FoundHardware f; f.axes = {"yaw", "pitch"}; f.imu = true; f.env = true; f.cameras = {"rgb"}; f.cameras_opened = 1; return f; } bool mentions(const PresenceReport& r, const std::string& subsystem) { return std::any_of(r.findings.begin(), r.findings.end(), [&](const PresenceFinding& f) { return f.subsystem == subsystem; }); } } // namespace TEST_CASE("an undeclared tower is never reconciled") { // Every tower is in this state until it gains a hardware.ini, and gaining // one must not be what changes its behaviour. HardwareConfig none; // declared == false FoundHardware bare; // nothing found at all bare.axes = {"yaw"}; auto r = reconcilePresence(none, bare); CHECK_FALSE(r.reconciled); CHECK(r.findings.empty()); CHECK(presenceVerdict(r, /*strict=*/true, /*degraded=*/false).action == PresenceAction::Proceed); } TEST_CASE("a matching rig produces no findings") { auto r = reconcilePresence(declaredTower(), foundTower()); CHECK(r.reconciled); CHECK(r.findings.empty()); CHECK_FALSE(r.anyMissing()); CHECK(presenceVerdict(r, true, false).action == PresenceAction::Proceed); } TEST_CASE("the bug this exists to catch: a declared axis that stopped enumerating") { // The 2-axis tower whose pitch vanished. Before reconciliation this looked // exactly like a healthy yaw-only tower. auto found = foundTower(); found.axes = {"yaw"}; auto r = reconcilePresence(declaredTower(), found); REQUIRE(r.anyMissing()); CHECK(mentions(r, "axis pitch")); CHECK(r.missing() == std::vector{"axis pitch"}); } TEST_CASE("an axis found but not declared is a stale file, never fatal") { auto declared = declaredTower(); declared.axes = {"yaw"}; // the file still describes the old single-axis rig auto r = reconcilePresence(declared, foundTower()); CHECK(r.anyUndeclared()); CHECK_FALSE(r.anyMissing()); CHECK(mentions(r, "axis pitch")); // Strict, no --degraded: still only a warning. More hardware than declared is // a documentation fault, not a broken tower. auto v = presenceVerdict(r, /*strict=*/true, /*degraded=*/false); CHECK(v.action == PresenceAction::WarnOnly); } TEST_CASE("an empty axes list states nothing and is not reconciled") { // Matches hasAxis(), which answers true for everything when nothing is said. auto declared = declaredTower(); declared.axes = {}; auto found = foundTower(); found.axes = {"yaw"}; auto r = reconcilePresence(declared, found); CHECK_FALSE(mentions(r, "axis yaw")); CHECK_FALSE(mentions(r, "axis pitch")); } TEST_CASE("no telemetry at all names the link, not every axis") { auto found = foundTower(); found.axes = {}; found.axes_known = false; auto r = reconcilePresence(declaredTower(), found); REQUIRE(r.anyMissing()); CHECK(mentions(r, "motor telemetry")); // The useful fault is the serial link; listing both axes would bury it. CHECK_FALSE(mentions(r, "axis yaw")); CHECK_FALSE(mentions(r, "axis pitch")); } TEST_CASE("sensors distinguish 'not fitted' from 'not stated'") { SUBCASE("declared and missing is a fault") { auto found = foundTower(); found.imu = false; auto r = reconcilePresence(declaredTower(), found); CHECK(mentions(r, "imu")); CHECK(r.anyMissing()); } SUBCASE("declared 'none' but running is a stale file") { auto declared = declaredTower(); declared.imu = "none"; auto r = reconcilePresence(declared, foundTower()); CHECK(mentions(r, "imu")); CHECK_FALSE(r.anyMissing()); CHECK(r.anyUndeclared()); } SUBCASE("not stated at all is silent in both directions") { auto declared = declaredTower(); declared.imu = ""; declared.env = ""; auto found = foundTower(); found.env = false; auto r = reconcilePresence(declared, found); CHECK_FALSE(mentions(r, "imu")); CHECK_FALSE(mentions(r, "env")); } } TEST_CASE("a camera that is configured but never opened is missing") { auto found = foundTower(); found.cameras_opened = 0; // configured, declared, not on the bus auto r = reconcilePresence(declaredTower(), found); REQUIRE(r.anyMissing()); CHECK(mentions(r, "cameras")); } TEST_CASE("an unasked camera count is not a shortfall") { auto found = foundTower(); found.cameras_opened = -1; // no camera source in this run auto r = reconcilePresence(declaredTower(), found); CHECK_FALSE(mentions(r, "cameras")); } TEST_CASE("the rollout default is warn-only, and says how to tighten it") { auto found = foundTower(); found.axes = {"yaw"}; auto r = reconcilePresence(declaredTower(), found); auto v = presenceVerdict(r, /*strict=*/false, /*degraded=*/false); CHECK(v.action == PresenceAction::WarnOnly); CHECK(v.reason.find("strict_presence") != std::string::npos); } TEST_CASE("strict refuses, and names both the fault and the escape") { auto found = foundTower(); found.axes = {"yaw"}; auto r = reconcilePresence(declaredTower(), found); auto v = presenceVerdict(r, /*strict=*/true, /*degraded=*/false); CHECK(v.action == PresenceAction::Refuse); CHECK(v.reason.find("axis pitch") != std::string::npos); CHECK(v.reason.find("--degraded") != std::string::npos); } TEST_CASE("--degraded overrides the refusal, and is loud about it") { auto found = foundTower(); found.axes = {"yaw"}; auto r = reconcilePresence(declaredTower(), found); auto v = presenceVerdict(r, /*strict=*/true, /*degraded=*/true); CHECK(v.action == PresenceAction::WarnOnly); // It must not read like a normal startup line. CHECK(v.reason.find("DEGRADED") != std::string::npos); } TEST_CASE("formatting puts the faults before the stale-file warnings") { auto declared = declaredTower(); declared.axes = {"yaw", "pitch"}; auto found = foundTower(); found.axes = {"yaw"}; // pitch missing -> a fault found.imu = true; declared.imu = "none"; // imu undeclared -> stale file auto r = reconcilePresence(declared, found); auto lines = formatPresence(r); REQUIRE(lines.size() == 2); CHECK(lines[0].rfind("axis pitch:", 0) == 0); CHECK(lines[1].rfind("imu:", 0) == 0); }