fwt_software/tests/test_config.cpp

380 lines
16 KiB
C++

#include <doctest/doctest.h>
#include "fgc/Config.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <unistd.h>
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"},
{"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<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("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<std::string, std::string> extra) {
std::map<std::string, std::string> 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"));
}