254 lines
11 KiB
C++
254 lines
11 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include "fgc/Config.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <fstream>
|
|
|
|
using namespace fgc;
|
|
|
|
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"},
|
|
{"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<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("ConfigLoader maps [Capture] and defaults to a gated trigger") {
|
|
AppConfig d = ConfigLoader::fromMap({});
|
|
CHECK(d.capture.mode == "trigger");
|
|
CHECK(d.capture.quality_gate == true);
|
|
CHECK(d.capture.max_attempts == 3);
|
|
CHECK(d.capture.min_attempts == 1);
|
|
CHECK(d.capture.target_mean == doctest::Approx(110.0));
|
|
CHECK(d.capture.blur_relative_floor == doctest::Approx(0.5));
|
|
CHECK(d.capture.blur_absolute_floor == doctest::Approx(0.0)); // backstop off by default
|
|
CHECK_FALSE(d.capture.exposure_store.empty()); // resolved to a default path
|
|
|
|
AppConfig c = ConfigLoader::fromMap({
|
|
{"Capture.mode", "freerun"},
|
|
{"Capture.quality_gate", "false"},
|
|
{"Capture.max_attempts", "5"},
|
|
{"Capture.min_attempts", "2"},
|
|
{"Capture.target_mean", "128"},
|
|
{"Capture.clip_max_fraction", "0.01"},
|
|
{"Capture.exposure_store", "/tmp/fgc_store.csv"},
|
|
{"Capture.store_stale_s", "600"},
|
|
});
|
|
CHECK(c.capture.mode == "freerun");
|
|
CHECK(c.capture.quality_gate == false);
|
|
CHECK(c.capture.max_attempts == 5);
|
|
CHECK(c.capture.min_attempts == 2);
|
|
CHECK(c.capture.target_mean == doctest::Approx(128.0));
|
|
CHECK(c.capture.clip_max_fraction == doctest::Approx(0.01));
|
|
CHECK(c.capture.exposure_store == "/tmp/fgc_store.csv");
|
|
CHECK(c.capture.store_stale_s == 600);
|
|
}
|
|
|
|
TEST_CASE("Capture config rejects settings that could not work") {
|
|
// A typo'd mode would silently pick one of the two acquisition paths.
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.mode", "triggered"}}));
|
|
// min > max would make the gate unable to ever accept.
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.max_attempts", "2"},
|
|
{"Capture.min_attempts", "3"}}));
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.max_attempts", "0"}}));
|
|
// A target outside the pixel range can never be reached.
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.target_mean", "300"}}));
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.clip_max_fraction", "1.5"}}));
|
|
// Damping of 0 would freeze the correction; above 1 would overshoot.
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.damping", "0"}}));
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.damping", "1.5"}}));
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Capture.metric_stride", "0"}}));
|
|
}
|
|
|
|
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("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());
|
|
}
|