#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("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("Camera imaging + encoding config: defaults and overrides") { // Defaults chosen for reliable transfer on the LattePanda USB3 host + near-lossless output. AppConfig d = ConfigLoader::fromMap({}); CHECK(d.camera.binning == 2); CHECK(d.camera.pixel_format == "RGB8"); CHECK(d.camera.throughput_mbytes == 250); CHECK(d.camera.stream_fps == doctest::Approx(1.0)); CHECK(d.camera.exposure_auto == true); CHECK(d.camera.gain_auto == true); CHECK(d.camera.white_balance_auto == true); CHECK(d.camera.jxl_distance == doctest::Approx(0.8)); CHECK(d.camera.jxl_effort == 4); AppConfig c = ConfigLoader::fromMap({ {"Camera.binning", "1"}, {"Camera.pixel_format", "BayerRG8"}, {"Camera.width", "2256"}, {"Camera.height", "2256"}, {"Camera.throughput_mbytes", "300"}, {"Camera.stream_fps", "1.5"}, {"Camera.exposure_auto", "false"}, {"Camera.exposure_max_us", "20000"}, {"Camera.gain_auto", "false"}, {"Camera.gain_max_db", "12"}, {"Camera.white_balance_auto", "false"}, {"Camera.jxl_distance", "0"}, {"Camera.jxl_effort", "6"}, }); CHECK(c.camera.binning == 1); CHECK(c.camera.pixel_format == "BayerRG8"); CHECK(c.camera.width == 2256); CHECK(c.camera.height == 2256); CHECK(c.camera.throughput_mbytes == 300); CHECK(c.camera.stream_fps == doctest::Approx(1.5)); CHECK(c.camera.exposure_auto == false); CHECK(c.camera.exposure_max_us == doctest::Approx(20000.0)); CHECK(c.camera.gain_auto == false); CHECK(c.camera.gain_max_db == doctest::Approx(12.0)); CHECK(c.camera.white_balance_auto == false); CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless CHECK(c.camera.jxl_effort == 6); } TEST_CASE("Camera config validates ranges") { CHECK_THROWS(ConfigLoader::fromMap({{"Camera.binning", "0"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.throughput_mbytes", "0"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Camera.stream_fps", "0"}})); 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"}})); } 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()); }