#include #include "fgc/Config.h" #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"}, {"Camera.id_Cam1", "DEV_1"}, {"Camera.id_Cam3", "DEV_3"}, {"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.camera.ids.size() == 2); // blank id_Cam2 skipped CHECK(c.camera.ids[1] == "DEV_3"); 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("Camera mode selection + encoding config: defaults and overrides") { AppConfig d = ConfigLoader::fromMap({}); CHECK(d.camera.mode == "standard"); CHECK(d.camera.mode_dir.find("config/camera_modes/rgb") != std::string::npos); CHECK(d.camera.jxl_distance == doctest::Approx(0.8)); CHECK(d.camera.jxl_effort == 4); CHECK(d.camera.rotation_deg == 0); AppConfig c = ConfigLoader::fromMap({ {"Camera.mode_dir", "/tmp/modes"}, {"Camera.mode", "night"}, {"Camera.jxl_distance", "0"}, {"Camera.jxl_effort", "6"}, {"Camera.rotation_deg", "180"}, }); CHECK(c.camera.mode_dir == "/tmp/modes"); CHECK(c.camera.mode == "night"); CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless CHECK(c.camera.jxl_effort == 6); CHECK(c.camera.rotation_deg == 180); } TEST_CASE("Camera config validates ranges") { // mode_dir / mode are not validated here: a blank value falls back to the // default, and whether the mode file actually exists is checked at load time // (loadCameraModes), which can say what IS available. CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_distance", "-1"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "0"}})); // effort 1..9 CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "10"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rotation_deg", "45"}})); // 0/90/180/270 only CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rotation_deg", "-90"}})); } 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()); }