fwt_software/tests/test_config.cpp

757 lines
34 KiB
C++

#include <doctest/doctest.h>
#include "fgc/Config.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <unistd.h>
using namespace fgc;
namespace {
// How many times `needle` appears in `hay`. Used to assert that calibration
// history replaces itself rather than accumulating.
int countOccurrences(const std::string& hay, const std::string& needle) {
int n = 0;
size_t i = hay.find(needle);
while (i != std::string::npos) {
++n;
i = hay.find(needle, i + needle.size());
}
return n;
}
} // namespace
TEST_CASE("ConfigLoader maps and defaults typed values") {
unsetenv("FGC_MQTT_USER");
unsetenv("FGC_MQTT_PW");
std::map<std::string, std::string> kv = {
{"General.tower_name", "Staeffelsberg"},
{"General.image_interval", "3"},
{"General.debug", "1"},
{"Network.zkms_server_ip", "10.0.0.5"},
{"Network.mqtt_user", "fileuser"},
{"Features.enable_mqtt", "false"},
};
AppConfig c = ConfigLoader::fromMap(kv);
CHECK(c.general.tower_name == "Staeffelsberg");
CHECK(c.general.image_interval == 3);
CHECK(c.general.debug == true);
CHECK(c.network.broker_ip == "10.0.0.5");
CHECK(c.network.mqtt_user == "fileuser");
CHECK(c.features.enable_mqtt == false);
CHECK(!c.paths.output_dir.empty()); // defaulted
CHECK(std::abs(c.image_rate() - 1.0 / 3.0) < 1e-9);
}
TEST_CASE("ConfigLoader maps [Env]/env feature flags") {
AppConfig d = ConfigLoader::fromMap({});
CHECK(d.features.enable_env == false);
CHECK(d.features.mock_env == false);
CHECK(d.env.i2c_device == "/dev/i2c-1");
CHECK(d.env.i2c_addr == 0x44);
CHECK(d.env.period_ms == 2000);
std::map<std::string, std::string> kv = {
{"Features.enable_env", "true"},
{"Features.mock_env", "true"},
{"Env.i2c_device", "/dev/i2c-3"},
{"Env.i2c_addr", "68"},
{"Env.period_ms", "5000"},
};
AppConfig c = ConfigLoader::fromMap(kv);
CHECK(c.features.enable_env == true);
CHECK(c.features.mock_env == true);
CHECK(c.env.i2c_device == "/dev/i2c-3");
CHECK(c.env.i2c_addr == 68);
CHECK(c.env.period_ms == 5000);
}
TEST_CASE("Env.i2c_addr accepts the hex form every datasheet uses") {
// Plain std::stoi() stops at the 'x' and returns 0, which would silently
// configure address 0x00 — the sensor would then never answer.
CHECK(ConfigLoader::fromMap({{"Env.i2c_addr", "0x44"}}).env.i2c_addr == 0x44);
CHECK(ConfigLoader::fromMap({{"Env.i2c_addr", "0X44"}}).env.i2c_addr == 0x44);
CHECK(ConfigLoader::fromMap({{"Env.i2c_addr", "68"}}).env.i2c_addr == 68);
// Decimal stays decimal: no surprise octal for a leading zero.
CHECK(ConfigLoader::fromMap({{"Env.i2c_addr", "010"}}).env.i2c_addr == 10);
CHECK_THROWS(ConfigLoader::fromMap({{"Env.i2c_addr", "0x4g"}}));
}
TEST_CASE("environment overrides file credentials") {
setenv("FGC_MQTT_USER", "envuser", 1);
AppConfig c = ConfigLoader::fromMap({{"Network.mqtt_user", "fileuser"}});
CHECK(c.network.mqtt_user == "envuser");
unsetenv("FGC_MQTT_USER");
}
TEST_CASE("ConfigLoader validates input") {
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "abc"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}}));
}
TEST_CASE("brokerUri builds a Paho serverURI from host + port") {
// The port used to be whatever Paho defaulted to when handed a bare address.
// It is now explicit, so these cases pin the whole resolution table.
auto uri = [](const std::string& host, int port) {
NetworkConfig n;
n.broker_ip = host;
n.mqtt_port = port;
return n.brokerUri();
};
CHECK(uri("10.11.12.20", 1883) == "tcp://10.11.12.20:1883");
CHECK(uri("broker.local", 8883) == "tcp://broker.local:8883");
// A port already in the host wins - existing configs that smuggled one in
// there must keep working.
CHECK(uri("10.11.12.20:1884", 1883) == "tcp://10.11.12.20:1884");
// A scheme means the operator owns the whole string: verbatim, port ignored.
// Appending to a ws:// path would corrupt it.
CHECK(uri("tcp://host:1883", 9999) == "tcp://host:1883");
CHECK(uri("ws://host/mqtt", 9999) == "ws://host/mqtt");
CHECK(uri("ssl://host:8883", 1883) == "ssl://host:8883");
// IPv6: bracketed passes through, bare gets bracketed - otherwise Paho's
// rightmost-colon parse reads the last hextet as the port.
CHECK(uri("[::1]", 1883) == "tcp://[::1]:1883");
CHECK(uri("[::1]:1884", 1883) == "tcp://[::1]:1884");
CHECK(uri("::1", 1883) == "tcp://[::1]:1883");
CHECK(uri("fe80::1234:5678", 1883) == "tcp://[fe80::1234:5678]:1883");
// The default config resolves to the MQTT default port.
CHECK(ConfigLoader::fromMap({}).network.brokerUri() == "tcp://127.0.0.1:1883");
CHECK(ConfigLoader::fromMap({{"Network.zkms_server_ip", "10.0.0.5"},
{"Network.mqtt_port", "8883"}})
.network.brokerUri() == "tcp://10.0.0.5:8883");
}
TEST_CASE("Network.mqtt_port is range-checked") {
CHECK(ConfigLoader::fromMap({}).network.mqtt_port == 1883);
CHECK(ConfigLoader::fromMap({{"Network.mqtt_port", "8883"}}).network.mqtt_port == 8883);
CHECK_THROWS(ConfigLoader::fromMap({{"Network.mqtt_port", "0"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Network.mqtt_port", "65536"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Network.mqtt_port", "-1"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"Network.mqtt_port", "1883x"}}));
}
TEST_CASE("Cameras are declared explicitly, one block per camera") {
// Identity is no longer positional: the block names the camera and `label`
// names its output, so nothing is implied by the order of the keys.
AppConfig c = ConfigLoader::fromMap({
{"Camera.jxl_distance", "0.8"},
{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.label", "RGB"},
{"Camera.rgb.profile_dir", "/tmp/profiles/rgb"},
{"Camera.rgb.profile", "night"},
{"Camera.rgb.rotation_deg", "180"},
{"Camera.rgb.jxl_effort", "6"},
{"Camera.nir.id", "DEV_2"},
{"Camera.nir.profile_dir", "/tmp/profiles/nir"},
});
REQUIRE(c.camera.cameras.size() == 2);
const CameraSpec* rgb = nullptr;
const CameraSpec* nir = nullptr;
for (const auto& s : c.camera.cameras) {
if (s.name == "rgb") rgb = &s;
if (s.name == "nir") nir = &s;
}
REQUIRE(rgb);
REQUIRE(nir);
CHECK(rgb->id == "DEV_1");
CHECK(rgb->label == "RGB");
CHECK(rgb->profile == "night");
CHECK(rgb->rotation_deg == 180);
CHECK(rgb->driver == "vimba");
CHECK(rgb->enabled);
// Per-camera override wins; anything not overridden falls back to [Camera].
CHECK(rgb->jxl_effort == 6);
CHECK(rgb->jxl_distance == doctest::Approx(0.8));
// label defaults to the block name rather than to a position in a list.
CHECK(nir->label == "nir");
CHECK(nir->profile == "standard");
}
TEST_CASE("Per-profile timing is per camera, and the scheduler takes the longest") {
AppConfig c = ConfigLoader::fromMap({
{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.rgb.profile", "standard"},
{"Camera.rgb.standard.settle_wait_ms", "1000"},
{"Camera.rgb.standard.frame_timeout_ms", "2000"},
{"Camera.nir.id", "DEV_2"},
{"Camera.nir.profile_dir", "/tmp/p"},
{"Camera.nir.profile", "standard"},
{"Camera.nir.standard.settle_wait_ms", "2500"},
{"Camera.nir.standard.frame_timeout_ms", "5000"},
});
// The gimbal must stand still long enough for EVERY camera, so the longest
// wait wins - taking the first camera's would short-change the slower sensor.
CHECK(c.camera.maxSettleWaitMs() == 2500);
CHECK(c.camera.maxFrameTimeoutMs() == 5000);
// A profile with no timing block falls back to the defaults.
CHECK(c.camera.cameras[0].timingFor("night").settle_wait_ms == 1000);
}
TEST_CASE("A disabled camera is excluded from the active set and its timing") {
AppConfig c = ConfigLoader::fromMap({
{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.rgb.standard.settle_wait_ms", "1000"},
{"Camera.slow.enabled", "false"},
{"Camera.slow.profile_dir", "/tmp/p"},
{"Camera.slow.standard.settle_wait_ms", "9000"},
});
CHECK(c.camera.cameras.size() == 2);
CHECK(c.camera.active().size() == 1);
CHECK(c.camera.maxSettleWaitMs() == 1000); // the disabled camera does not slow the scan
}
TEST_CASE("Camera blocks reject what cannot work") {
auto base = [](std::map<std::string, std::string> extra) {
std::map<std::string, std::string> kv = {{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"}};
for (auto& [k, v] : extra) kv[k] = v;
return kv;
};
CHECK_NOTHROW(ConfigLoader::fromMap(base({})));
// No second backend exists yet; accepting the key silently would be worse.
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.driver", "thermal"}})));
// An enabled camera with no id cannot be opened.
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.profile_dir", "/tmp/p"}}));
// Nothing to load settings from.
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"}}));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.rotation_deg", "45"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.jxl_distance", "-1"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.jxl_effort", "10"}})));
// A typo'd timing key would otherwise be silently ignored.
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.standard.settle_wait", "500"}})));
}
TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") {
const std::string in =
"[General]\n"
"tower_name = Foo\n"
"[Motor]\n"
"; calibrate me\n"
"yaw_counts_per_deg = 983.33\n"
"yaw_center_count = 500000\n"
"yaw_min_deg = -90\n"
"[Scan]\n"
"yaw_min_deg = -90\n"; // same key name, different section — must NOT change
auto out = updateIniSectionKeys(in, "Motor",
{{"yaw_counts_per_deg", "1000"}, {"yaw_center_count", "12345"}});
CHECK(out.find("yaw_counts_per_deg = 1000") != std::string::npos);
CHECK(out.find("yaw_center_count = 12345") != std::string::npos);
CHECK(out.find("yaw_counts_per_deg = 983.33") == std::string::npos); // replaced
CHECK(out.find("; calibrate me") != std::string::npos); // comment kept
CHECK(out.find("tower_name = Foo") != std::string::npos); // other section kept
CHECK(out.find("[Motor]\nyaw_min_deg") == std::string::npos); // Motor's other key kept in place
CHECK(out.find("[Scan]\nyaw_min_deg = -90") != std::string::npos); // Scan untouched
}
TEST_CASE("updateIniSectionKeys appends keys missing from the section") {
const std::string in = "[Motor]\nyaw_counts_per_deg = 1\n";
auto out = updateIniSectionKeys(in, "Motor", {{"pitch_center_count", "7"}});
// re-parsing must see the appended key under [Motor]
CHECK(out.find("pitch_center_count = 7") != std::string::npos);
}
TEST_CASE("updateIniSectionKeys keeps the superseded value as a ';prev' comment") {
const std::string in =
"[Motor]\n"
"; the heading the gimbal points at when parked\n"
"yaw_heading_at_center = 0.0\n"
"yaw_center_count = 500000\n";
auto out = updateIniSectionKeys(in, "Motor",
{{"yaw_heading_at_center", "127.4"},
{"yaw_center_count", "500000"}}, // unchanged
"2026-09-11T14:32Z, gimbal heading setup");
SUBCASE("the changed key keeps its old value, stamped") {
CHECK(out.find(";prev yaw_heading_at_center = 0.0") != std::string::npos);
CHECK(out.find("2026-09-11T14:32Z, gimbal heading setup") != std::string::npos);
CHECK(out.find("yaw_heading_at_center = 127.4") != std::string::npos);
}
SUBCASE("the history sits directly above the value it superseded") {
CHECK(out.find(";prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal "
"heading setup\nyaw_heading_at_center = 127.4") != std::string::npos);
}
SUBCASE("an UNCHANGED key gets no history - otherwise every save adds noise") {
CHECK(out.find(";prev yaw_center_count") == std::string::npos);
}
SUBCASE("hand-written comments survive") {
CHECK(out.find("; the heading the gimbal points at when parked") != std::string::npos);
}
}
TEST_CASE("updateIniSectionKeys keeps exactly one generation of history") {
// Saving twice must REPLACE the marker, not stack markers up: the file has to
// grow once and then hold steady however often the gimbal is recalibrated.
std::string s = "[Motor]\nyaw_heading_at_center = 0.0\n";
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "10"}}, "t1, first");
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "20"}}, "t2, second");
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "30"}}, "t3, third");
CHECK(countOccurrences(s, ";prev yaw_heading_at_center") == 1);
CHECK(s.find(";prev yaw_heading_at_center = 20") != std::string::npos); // the one before
CHECK(s.find("yaw_heading_at_center = 30") != std::string::npos);
CHECK(s.find("= 0.0") == std::string::npos); // the oldest is gone
CHECK(s.find("t3, third") != std::string::npos);
CHECK(s.find("t1, first") == std::string::npos);
}
TEST_CASE("updateIniSectionKeys history respects inline comments and unchanged values") {
// inih treats ' ;' as an inline comment, so the stored value is "983.33", not
// "983.33 ; measured". Comparing raw text instead would call this key changed
// on every save and bury the file in useless history.
const std::string in = "[Motor]\nyaw_counts_per_deg = 983.33 ; measured on the rig\n";
SUBCASE("same value behind an inline comment counts as unchanged") {
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_counts_per_deg", "983.33"}}, "t, c");
CHECK(out.find(";prev") == std::string::npos);
}
SUBCASE("a real change records only the parsed value, not the comment") {
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_counts_per_deg", "1000"}}, "t, c");
CHECK(out.find(";prev yaw_counts_per_deg = 983.33 ; t, c") != std::string::npos);
}
}
TEST_CASE("updateIniSectionKeys writes no history without a provenance") {
// The default keeps the old behaviour, so callers that have nothing useful to
// say about where the values came from do not litter the file.
const std::string in = "[Motor]\nyaw_center_count = 1\n";
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_center_count", "2"}});
CHECK(out.find(";prev") == std::string::npos);
CHECK(out.find("yaw_center_count = 2") != std::string::npos);
}
TEST_CASE("a ';prev' comment does not survive a reload as a real key") {
// The marker has to be inert to inih, or reverting by uncommenting would be
// pointless: the stale value would already be in force.
std::string s = "[General]\ntower_name = T\n[Motor]\nyaw_center_count = 1\n";
s = updateIniSectionKeys(s, "Motor", {{"yaw_center_count", "2"}}, "t, c");
REQUIRE(s.find(";prev yaw_center_count = 1") != std::string::npos);
const std::string path = "test_prev_marker_reload.ini";
{ std::ofstream(path) << s; }
auto cfg = ConfigLoader::loadFromFile(path);
CHECK(cfg.geometry.yaw.center_count == 2);
std::filesystem::remove(path);
}
TEST_CASE("saveMotorGeometry round-trips the full per-axis map") {
// Write a starting config, save a calibrated Geometry into it, reload, and
// confirm every [Motor] parameter survives, while another section is left
// untouched. min_deg/max_deg are deliberately NOT persisted - they come from
// homing - so they must come back at their defaults, not at the saved values.
const std::string path = "test_calib_roundtrip.ini";
{
std::ofstream f(path, std::ios::trunc);
f << "[General]\ntower_name = rig7\n\n"
"[Motor]\n"
"yaw_counts_per_deg = 1.0\n"
"yaw_center_count = 500000\n"
"yaw_heading_at_center = 0\n"
"pitch_counts_per_deg = 1.0\n"
"pitch_center_count = 500000\n"
"pitch_angle_at_center = 0\n";
}
Geometry geo;
geo.yaw = {983.33, 500123, 137.25, -88.0, 92.0};
geo.pitch = {8333.33, 499000, 10.5, -2.0, 61.5};
REQUIRE(saveMotorGeometry(path, geo, 4000, 6000));
AppConfig cfg = ConfigLoader::loadFromFile(path);
CHECK(cfg.general.tower_name == "rig7"); // unrelated section preserved
CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(983.33));
CHECK(cfg.geometry.yaw.center_count == 500123);
CHECK(cfg.geometry.yaw.deg_at_center == doctest::Approx(137.25));
CHECK(cfg.geometry.pitch.counts_per_deg == doctest::Approx(8333.33));
CHECK(cfg.geometry.pitch.center_count == 499000);
CHECK(cfg.geometry.pitch.deg_at_center == doctest::Approx(10.5));
CHECK(cfg.yaw_soft_margin_counts == 4000);
CHECK(cfg.pitch_soft_margin_counts == 6000);
std::remove(path.c_str());
}
TEST_CASE("the [Motor] loader rejects values that would silently misaim the gimbal") {
auto base = [] {
return std::map<std::string, std::string>{
{"Motor.yaw_counts_per_deg", "983.33"}, {"Motor.yaw_center_count", "500000"},
{"Motor.pitch_counts_per_deg", "8333.33"}, {"Motor.pitch_center_count", "500000"}};
};
CHECK_NOTHROW(ConfigLoader::fromMap(base()));
// A zero scale maps every degree to the centre: it would look calibrated.
auto zero_scale = base();
zero_scale["Motor.yaw_counts_per_deg"] = "0";
CHECK_THROWS_AS(ConfigLoader::fromMap(zero_scale), std::runtime_error);
// The centre count exists to keep the working range positive.
auto neg_centre = base();
neg_centre["Motor.pitch_center_count"] = "-1";
CHECK_THROWS_AS(ConfigLoader::fromMap(neg_centre), std::runtime_error);
auto neg_margin = base();
neg_margin["Motor.yaw_soft_margin_counts"] = "-100";
CHECK_THROWS_AS(ConfigLoader::fromMap(neg_margin), std::runtime_error);
}
TEST_CASE("keys from the old [Motor] layout are ignored, not silently honoured") {
// A config still carrying yaw_zero_count must load (so an upgrade is not a
// hard stop) but must not pick the stale origin up from anywhere.
std::map<std::string, std::string> kv{
{"Motor.yaw_counts_per_deg", "983.33"}, {"Motor.yaw_center_count", "500000"},
{"Motor.yaw_heading_at_center", "42"}, {"Motor.yaw_zero_count", "851592"},
{"Motor.yaw_min_deg", "0"}, {"Motor.yaw_max_deg", "319"},
{"Motor.yaw_home_zero", "high"}};
AppConfig cfg = ConfigLoader::fromMap(kv);
CHECK(cfg.geometry.yaw.center_count == 500000);
CHECK(cfg.geometry.yaw.deg_at_center == doctest::Approx(42.0));
// The removed range keys must not have leaked into the derived window.
CHECK(cfg.geometry.yaw.min_deg == doctest::Approx(-100000.0));
CHECK(cfg.geometry.yaw.max_deg == doctest::Approx(100000.0));
}
TEST_CASE("loadFromFile anchors a relative profile_dir to the config file's directory") {
// The config is found through a search path that has nothing to do with the
// working directory, so a relative profile_dir must resolve next to the
// config.ini itself. Resolving it against the cwd is how a profile directory
// silently goes missing when the program is started from elsewhere.
namespace fs = std::filesystem;
const fs::path dir = fs::temp_directory_path() /
("fgc_cfgdir_" + std::to_string(::getpid()));
fs::remove_all(dir);
fs::create_directories(dir / "config" / "camera_profiles" / "rgb");
const fs::path path = dir / "config.ini";
{
std::ofstream f(path, std::ios::trunc);
f << "[Camera.rgb]\n"
"id = DEV_1\n"
"profile_dir = config/camera_profiles/rgb\n"
"[Camera.nir]\n"
"id = DEV_2\n"
"profile_dir = /opt/profiles/nir\n"
"[Camera.swir]\n"
"id = DEV_3\n"
"profile_dir = ~/profiles/swir\n";
}
AppConfig cfg = ConfigLoader::loadFromFile(path.string());
const CameraSpec* rgb = nullptr;
const CameraSpec* nir = nullptr;
const CameraSpec* swir = nullptr;
for (const auto& c : cfg.camera.cameras) {
if (c.name == "rgb") rgb = &c;
if (c.name == "nir") nir = &c;
if (c.name == "swir") swir = &c;
}
REQUIRE(rgb);
REQUIRE(nir);
REQUIRE(swir);
CHECK(rgb->profile_dir == (dir / "config" / "camera_profiles" / "rgb").string());
// An absolute path is the operator's explicit choice and is left alone.
CHECK(nir->profile_dir == "/opt/profiles/nir");
// ~ expands to the home directory, not to a literal "~" subdirectory.
CHECK(swir->profile_dir.rfind("~", 0) != 0);
CHECK(swir->profile_dir.find("/profiles/swir") != std::string::npos);
fs::remove_all(dir);
}
TEST_CASE("[IMU] x/y/z describe the mount and default to no rotation") {
// Absent keys must leave the identity, so an existing config.ini keeps
// behaving exactly as it did before the mount existed.
AppConfig none = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"}});
CHECK(none.imu.mount.identity());
AppConfig c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"IMU.x", "180"},
{"IMU.y", "-0.5"},
{"IMU.z", "-90"}});
CHECK(c.imu.mount.x_deg == doctest::Approx(180.0));
CHECK(c.imu.mount.y_deg == doctest::Approx(-0.5));
CHECK(c.imu.mount.z_deg == doctest::Approx(-90.0));
CHECK_FALSE(c.imu.mount.identity());
}
TEST_CASE("hardware.ini layers over config.ini and wins") {
const std::string cfg_path = "test_hw_layer_config.ini";
const std::string hw_path = "test_hw_layer_hardware.ini";
{
std::ofstream c(cfg_path);
c << "[General]\ntower_name = NorthRidge\nimage_interval = 3\n"
"[Camera.rgb]\nid = OLD_ID\nprofile_dir = /tmp/p\n"
"[Serial]\ndevice = /dev/ttyACM0\n";
std::ofstream h(hw_path);
h << "[Board]\nmodel = lattepanda-iota\nfirmware_env = iota_rp2040\n"
"[Hardware]\naxes = yaw, pitch\nimu = none\nenv = sht41\ncameras = rgb\n"
"[Serial]\ndevice = /dev/serial/by-id/usb-RP2040-if00\n"
"[Camera.rgb]\nid = NEW_ID\n";
}
auto c = ConfigLoader::loadFromFile(cfg_path, hw_path);
SUBCASE("assembly data arrives from hardware.ini") {
CHECK(c.board.model == "lattepanda-iota");
CHECK(c.board.firmware_env == "iota_rp2040");
CHECK(c.hardware.axes == std::vector<std::string>{"yaw", "pitch"});
CHECK(c.hardware.imu == "none");
CHECK(c.hardware.env == "sht41");
CHECK(c.hardware.cameras == std::vector<std::string>{"rgb"});
CHECK(c.hardware.declared);
CHECK(c.hardware.source_path == hw_path);
}
SUBCASE("bindings in hardware.ini override the ones left in config.ini") {
CHECK(c.serial.device == "/dev/serial/by-id/usb-RP2040-if00");
REQUIRE(c.camera.cameras.size() == 1);
CHECK(c.camera.cameras[0].id == "NEW_ID");
}
SUBCASE("operational settings stay with config.ini") {
CHECK(c.general.tower_name == "NorthRidge");
CHECK(c.general.image_interval == 3);
}
std::filesystem::remove(cfg_path);
std::filesystem::remove(hw_path);
}
TEST_CASE("without a hardware.ini nothing changes and nothing is declared") {
// The migration guarantee: an un-migrated tower must behave exactly as before.
const std::string path = "test_hw_absent.ini";
{
std::ofstream c(path);
c << "[General]\ntower_name = Old\n"
"[Camera.rgb]\nid = D\nprofile_dir = /tmp/p\n"
"[Serial]\ndevice = /dev/ttyACM0\n";
}
auto c = ConfigLoader::loadFromFile(path);
CHECK_FALSE(c.hardware.declared);
CHECK(c.hardware.axes.empty());
CHECK(c.serial.device == "/dev/ttyACM0");
CHECK(c.general.tower_name == "Old");
// An undeclared rig must not answer "no" to any axis, or every caller that
// asks would start refusing work on towers that have not been migrated.
CHECK(c.hardware.hasAxis("yaw"));
CHECK(c.hardware.hasAxis("pitch"));
std::filesystem::remove(path);
}
TEST_CASE("Hardware.axes parsing and membership") {
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Hardware.axes", " YAW ,, pitch ,"}});
SUBCASE("case, spacing and stray commas are tolerated") {
CHECK(c.hardware.axes == std::vector<std::string>{"yaw", "pitch"});
CHECK(c.hardware.axesLabel() == "yaw, pitch");
}
SUBCASE("a declared yaw-only rig answers no to pitch") {
auto y = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Hardware.axes", "yaw"}});
CHECK(y.hardware.hasAxis("yaw"));
CHECK_FALSE(y.hardware.hasAxis("pitch"));
}
SUBCASE("an unknown axis is a typo worth refusing, not ignoring") {
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Hardware.axes", "yaw, roll"}}));
}
}
TEST_CASE("[Pins] is read for comparison, and absent reads as undeclared") {
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Pins.yaw_cs", "1"},
{"Pins.yaw_en", "4"},
{"Pins.spi_sck", "2"},
{"Pins.spi_miso", "0"}});
CHECK(c.hardware.pins.declared());
CHECK(c.hardware.pins.yaw_cs == 1);
CHECK(c.hardware.pins.yaw_en == 4);
CHECK(c.hardware.pins.spi_sck == 2);
CHECK(c.hardware.pins.spi_miso == 0); // GP0 is a pin, not "unset"
CHECK(c.hardware.pins.pitch_cs == -1);
auto none = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"}});
CHECK_FALSE(none.hardware.pins.declared());
}
TEST_CASE("A mount angle that cannot be a rotation is rejected") {
auto with = [](const std::string& k, const std::string& v) {
return ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{k, v}});
};
// Out of range is a typo far more often than an intent, and one accepted
// silently is invisible afterwards - the readings just describe wrong axes.
CHECK_THROWS(with("IMU.z", "900"));
CHECK_THROWS(with("IMU.x", "-361"));
CHECK_THROWS(with("IMU.y", "nan"));
CHECK_NOTHROW(with("IMU.z", "-360"));
CHECK_NOTHROW(with("IMU.z", "360"));
}
TEST_CASE("[Motor.<axis>] drivetrain keys are parsed per axis") {
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.r_sense_ohm", "0.075"},
{"Motor.yaw.run_current_ma", "756"},
{"Motor.yaw.homing_current_ma", "200"},
{"Motor.yaw.travel_deg", "340"},
});
CHECK(cfg.drivetrain.yaw.declared);
CHECK(cfg.drivetrain.yaw.r_sense_ohm == doctest::Approx(0.075));
CHECK(cfg.drivetrain.yaw.run_current_ma == doctest::Approx(756.0));
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(200.0));
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
CHECK(cfg.drivetrain.yaw.reducesForHoming());
// The other axis stays undeclared, so nothing is sent for it.
CHECK_FALSE(cfg.drivetrain.pitch.declared);
}
TEST_CASE("an axis that names only a run current is not asking for a reduction") {
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.r_sense_ohm", "0.075"},
{"Motor.yaw.run_current_ma", "756"},
});
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(756.0));
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
}
TEST_CASE("[Motor.yaw] travel_deg does not shadow [Motor] yaw_counts_per_deg") {
// The whole reason the travel angle is a separate key in a separate section:
// hardware.ini is merged OVER config.ini and wins, so if the derived
// calibration shared a key with the nameplate fact, every saved calibration
// would be silently discarded on the next start.
auto cfg = ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw_counts_per_deg", "3561.1"}, // calibration, from config.ini
{"Motor.yaw.travel_deg", "340"}, // nameplate, from hardware.ini
});
CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(3561.1));
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
}
TEST_CASE("a drivetrain current without its sense resistor is refused") {
// Computing registers against a guessed resistor would silently deliver the
// wrong current, which on a rig with a yielding endstop is the whole hazard.
CHECK_THROWS(ConfigLoader::fromMap({
{"General.tower_name", "t"},
{"Motor.yaw.run_current_ma", "756"},
}));
}
TEST_CASE("an undeclared drivetrain leaves every axis silent") {
auto cfg = ConfigLoader::fromMap({{"General.tower_name", "t"}});
CHECK_FALSE(cfg.drivetrain.yaw.declared);
CHECK_FALSE(cfg.drivetrain.pitch.declared);
CHECK(cfg.drivetrain.yaw.run_current_ma == 0.0);
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
}
TEST_CASE("a value that only changes spelling gets no history line") {
// The writer formats doubles with %.10g, so a hand-written "0.0" comes back
// as "0". That is the same number, and recording it as a change would spend
// the single ";prev" generation on a value that never moved - displacing the
// real previous value the mechanism exists to preserve.
const std::string before = "[Motor]\nyaw_heading_at_center = 0.0\n";
const std::string after =
updateIniSectionKeys(before, "Motor", {{"yaw_heading_at_center", "0"}}, "a save");
CHECK(after.find(";prev") == std::string::npos);
CHECK(after.find("yaw_heading_at_center = 0") != std::string::npos);
}
TEST_CASE("differently spelled equal numbers are not changes") {
for (auto [old_text, new_text] : std::vector<std::pair<std::string, std::string>>{
{"1e3", "1000"}, {"500.0", "500"}, {"0.50", "0.5"}, {" 12 ", "12"}}) {
const std::string out = updateIniSectionKeys("[Motor]\nk = " + old_text + "\n", "Motor",
{{"k", new_text}}, "a save");
CHECK(out.find(";prev") == std::string::npos);
}
}
TEST_CASE("a genuine numeric change still records its history") {
const std::string out = updateIniSectionKeys("[Motor]\nyaw_counts_per_deg = 500\n", "Motor",
{{"yaw_counts_per_deg", "3552.867647"}},
"homed travel");
CHECK(out.find(";prev yaw_counts_per_deg = 500") != std::string::npos);
CHECK(out.find("homed travel") != std::string::npos);
CHECK(out.find("yaw_counts_per_deg = 3552.867647") != std::string::npos);
}
TEST_CASE("non-numeric values still compare as text") {
const std::string out = updateIniSectionKeys("[Serial]\ndevice = /dev/ttyACM0\n", "Serial",
{{"device", "/dev/ttyUSB0"}}, "a save");
CHECK(out.find(";prev device = /dev/ttyACM0") != std::string::npos);
}
// --- [Hardware] imu/env retiring [Features] enable_imu/enable_env (plan A.12) ---
TEST_CASE("an undeclared rig still obeys the legacy flag exactly as before") {
// Every tower until it is migrated. Behaviour must not change.
auto on = resolveSensorEnable("", true, true);
CHECK(on.enabled);
CHECK(on.source == SensorEnableSource::Flag);
CHECK_FALSE(on.conflict);
auto off = resolveSensorEnable("", false, true);
CHECK_FALSE(off.enabled);
auto absent = resolveSensorEnable("", false, false);
CHECK_FALSE(absent.enabled);
CHECK(absent.source == SensorEnableSource::Default);
}
TEST_CASE("a declared sensor needs no second key agreeing with it") {
// The trap this removes: declare the IMU, forget enable_imu, and presence
// reconciliation reports missing HARDWARE for what is a missing flag.
auto e = resolveSensorEnable("mti-630", /*flag_value=*/false, /*flag_present=*/false);
CHECK(e.enabled);
CHECK(e.source == SensorEnableSource::Declaration);
CHECK_FALSE(e.conflict);
}
TEST_CASE("'none' cannot be contradicted into enabling a sensor") {
auto e = resolveSensorEnable("none", /*flag_value=*/true, /*flag_present=*/true);
CHECK_FALSE(e.enabled);
CHECK(e.source == SensorEnableSource::Declaration);
CHECK(e.conflict); // named, so the stale key can be deleted
}
TEST_CASE("a declaration beats a flag that disagrees, and the clash is reported") {
auto e = resolveSensorEnable("sht41", /*flag_value=*/false, /*flag_present=*/true);
CHECK(e.enabled);
CHECK(e.conflict);
// Agreeing is not a conflict, merely redundant.
auto agree = resolveSensorEnable("sht41", true, true);
CHECK(agree.enabled);
CHECK_FALSE(agree.conflict);
}