#include #include "fgc/Config.h" #include "fgc/IniEdit.h" #include #include #include #include #include #include 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 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 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("[Startup] defaults to the idle start: no homing, manual") { AppConfig d = ConfigLoader::fromMap({}); CHECK(d.startup.home == false); CHECK(d.startup.mode == Mode::Manual); AppConfig e = ConfigLoader::fromMap({{"Startup.home", ""}, {"Startup.mode", ""}}); CHECK(e.startup.home == false); CHECK(e.startup.mode == Mode::Manual); } TEST_CASE("[Startup] home and mode are read") { AppConfig c = ConfigLoader::fromMap({{"Startup.home", "true"}, {"Startup.mode", "auto"}}); CHECK(c.startup.home == true); CHECK(c.startup.mode == Mode::Auto); AppConfig m = ConfigLoader::fromMap({{"Startup.home", "false"}, {"Startup.mode", "manual"}}); CHECK(m.startup.home == false); CHECK(m.startup.mode == Mode::Manual); } TEST_CASE("[Startup] refuses a mode it cannot start in") { CHECK_THROWS(ConfigLoader::fromMap({{"Startup.mode", "atuo"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Startup.mode", "pan"}})); // needs a spec CHECK_THROWS(ConfigLoader::fromMap({{"Startup.home", "maybe"}})); } TEST_CASE("the example config starts idle-but-homed, like a new tower") { const auto kv = parseIniString([] { std::ifstream f(std::string(FGC_SOURCE_DIR) + "/config/config.example.ini"); return std::string(std::istreambuf_iterator(f), {}); }()); AppConfig c = ConfigLoader::fromMap(kv); CHECK(c.startup.home == true); CHECK(c.startup.mode == Mode::Manual); } 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 extra) { std::map 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("a key the section lacks goes into that section, not a second block") { const std::string in = "[Hardware]\naxes = yaw\nenv = dht11\n\n; about pins\n[Pins]\n" "yaw_cs = 8\n\n[Camera.rgb]\nid = X\n"; auto out = updateIniSectionKeys(in, "Hardware", {{"cooling", "fan"}}, "t1, fan test"); CHECK(out.find("[Hardware]") == out.rfind("[Hardware]")); // still one block CHECK(out.find("env = dht11\n; added ; t1, fan test\ncooling = fan\n") != std::string::npos); // right after its keys CHECK(out.find("cooling = fan") < out.find("; about pins")); // before the next one's comment auto kv = parseIniString(out); CHECK(kv["Hardware.cooling"] == "fan"); // A section the file does not have is still appended whole. auto out2 = updateIniSectionKeys(in, "Env", {{"i2c_device", "/dev/i2c-2"}}); CHECK(out2.find("\n[Env]\ni2c_device = /dev/i2c-2\n") != 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{ {"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 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{"yaw", "pitch"}); CHECK(c.hardware.imu == "none"); CHECK(c.hardware.env == "sht41"); CHECK(c.hardware.cameras == std::vector{"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{"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.] 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>{ {"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); } TEST_CASE("image saving and de-Bayer settings: defaults, sharing the cores, limits") { auto base = [](std::map extra) { std::map kv = {{"Camera.rgb.id", "DEV_1"}, {"Camera.rgb.profile_dir", "/tmp/p"}}; for (auto& [k, v] : extra) kv[k] = v; return ConfigLoader::fromMap(kv); }; AppConfig c = base({}); CHECK(c.hardware.save_workers == 1); CHECK(c.hardware.jxl_threads == 4); // 4 / save_workers CHECK(c.hardware.save_queue_max == 4); REQUIRE(c.camera.cameras.size() == 1); CHECK(c.camera.cameras[0].demosaic == "edge_aware"); // Two workers share the four cores unless told otherwise (the 3 Delta). c = base({{"Hardware.save_workers", "2"}}); CHECK(c.hardware.jxl_threads == 2); c = base({{"Hardware.save_workers", "2"}, {"Hardware.jxl_threads", "3"}}); CHECK(c.hardware.jxl_threads == 3); // [Camera] default, overridden per camera; case does not matter. c = base({{"Camera.demosaic", "Bilinear"}}); CHECK(c.camera.cameras[0].demosaic == "bilinear"); c = base({{"Camera.demosaic", "bilinear"}, {"Camera.rgb.demosaic", "edge_aware"}}); CHECK(c.camera.cameras[0].demosaic == "edge_aware"); CHECK_THROWS(base({{"Camera.rgb.demosaic", "vng"}})); CHECK_THROWS(base({{"Hardware.save_workers", "0"}})); CHECK_THROWS(base({{"Hardware.save_workers", "5"}})); CHECK_THROWS(base({{"Hardware.jxl_threads", "9"}})); CHECK_THROWS(base({{"Hardware.save_queue_max", "0"}})); CHECK_THROWS(base({{"Hardware.save_queue_max", "33"}})); } TEST_CASE("each profile chooses its keep rule: fixed (default) or brightness") { auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"}, {"Camera.rgb.profile_dir", "/tmp/p"}, {"Camera.rgb.standard.settle_rule", "Brightness"}, {"Camera.rgb.standard.frame_timeout_ms", "5000"}, {"Camera.rgb.night.settle_wait_ms", "2500"}}); REQUIRE(c.camera.cameras.size() == 1); const auto& cam = c.camera.cameras[0]; CHECK(cam.timingFor("standard").settle_rule == "brightness"); CHECK(cam.timingFor("standard").frame_timeout_ms == 5000); CHECK(cam.timingFor("night").settle_rule == "fixed"); CHECK(cam.timingFor("unnamed").settle_rule == "fixed"); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"}, {"Camera.rgb.profile_dir", "/tmp/p"}, {"Camera.rgb.standard.settle_rule", "magic"}})); }