#include #include "fgc/Config.h" #include #include #include #include #include #include using namespace fgc; 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("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_zero_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_zero_count", "12345"}}); CHECK(out.find("yaw_counts_per_deg = 1000") != std::string::npos); CHECK(out.find("yaw_zero_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_zero_count", "7"}}); // re-parsing must see the appended key under [Motor] CHECK(out.find("pitch_zero_count = 7") != std::string::npos); } TEST_CASE("saveMotorCalibration round-trips the full per-axis map") { // Write a starting config, save a calibrated Geometry into it, reload, and // confirm every [Motor] parameter (incl. min/max_deg) survives, while another // section is left untouched. 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_zero_count = 0\n" "yaw_min_deg = -90\n" "yaw_max_deg = 90\n" "pitch_counts_per_deg = 1.0\n" "pitch_zero_count = 0\n" "pitch_min_deg = 0\n" "pitch_max_deg = 60\n"; } Geometry geo; geo.yaw = {983.33, 500123, -88.0, 92.0}; geo.pitch = {8333.33, -4200, -2.0, 61.5}; REQUIRE(saveMotorCalibration(path, geo)); 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.zero_count == 500123); CHECK(cfg.geometry.yaw.min_deg == doctest::Approx(-88.0)); CHECK(cfg.geometry.yaw.max_deg == doctest::Approx(92.0)); CHECK(cfg.geometry.pitch.counts_per_deg == doctest::Approx(8333.33)); CHECK(cfg.geometry.pitch.zero_count == -4200); CHECK(cfg.geometry.pitch.min_deg == doctest::Approx(-2.0)); CHECK(cfg.geometry.pitch.max_deg == doctest::Approx(61.5)); std::remove(path.c_str()); } 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("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")); }