862 lines
39 KiB
C++
862 lines
39 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include "fgc/Config.h"
|
|
#include "fgc/IniEdit.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
|
|
#include <unistd.h>
|
|
|
|
using namespace fgc;
|
|
|
|
namespace {
|
|
// How many times `needle` appears in `hay`. Used to assert that calibration
|
|
// history replaces itself rather than accumulating.
|
|
int countOccurrences(const std::string& hay, const std::string& needle) {
|
|
int n = 0;
|
|
size_t i = hay.find(needle);
|
|
while (i != std::string::npos) {
|
|
++n;
|
|
i = hay.find(needle, i + needle.size());
|
|
}
|
|
return n;
|
|
}
|
|
} // namespace
|
|
|
|
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("[Startup] defaults to the idle start: no homing, manual") {
|
|
AppConfig d = ConfigLoader::fromMap({});
|
|
CHECK(d.startup.home == false);
|
|
CHECK(d.startup.mode == Mode::Manual);
|
|
|
|
AppConfig e = ConfigLoader::fromMap({{"Startup.home", ""}, {"Startup.mode", ""}});
|
|
CHECK(e.startup.home == false);
|
|
CHECK(e.startup.mode == Mode::Manual);
|
|
}
|
|
|
|
TEST_CASE("[Startup] home and mode are read") {
|
|
AppConfig c = ConfigLoader::fromMap({{"Startup.home", "true"}, {"Startup.mode", "auto"}});
|
|
CHECK(c.startup.home == true);
|
|
CHECK(c.startup.mode == Mode::Auto);
|
|
|
|
AppConfig m = ConfigLoader::fromMap({{"Startup.home", "false"}, {"Startup.mode", "manual"}});
|
|
CHECK(m.startup.home == false);
|
|
CHECK(m.startup.mode == Mode::Manual);
|
|
}
|
|
|
|
TEST_CASE("[Startup] refuses a mode it cannot start in") {
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Startup.mode", "atuo"}}));
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Startup.mode", "pan"}})); // needs a spec
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Startup.home", "maybe"}}));
|
|
}
|
|
|
|
TEST_CASE("the example config starts idle-but-homed, like a new tower") {
|
|
const auto kv = parseIniString([] {
|
|
std::ifstream f(std::string(FGC_SOURCE_DIR) + "/config/config.example.ini");
|
|
return std::string(std::istreambuf_iterator<char>(f), {});
|
|
}());
|
|
AppConfig c = ConfigLoader::fromMap(kv);
|
|
CHECK(c.startup.home == true);
|
|
CHECK(c.startup.mode == Mode::Manual);
|
|
}
|
|
|
|
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_center_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_center_count", "12345"}});
|
|
|
|
CHECK(out.find("yaw_counts_per_deg = 1000") != std::string::npos);
|
|
CHECK(out.find("yaw_center_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_center_count", "7"}});
|
|
// re-parsing must see the appended key under [Motor]
|
|
CHECK(out.find("pitch_center_count = 7") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("a key the section lacks goes into that section, not a second block") {
|
|
const std::string in = "[Hardware]\naxes = yaw\nenv = dht11\n\n; about pins\n[Pins]\n"
|
|
"yaw_cs = 8\n\n[Camera.rgb]\nid = X\n";
|
|
auto out = updateIniSectionKeys(in, "Hardware", {{"cooling", "fan"}}, "t1, fan test");
|
|
CHECK(out.find("[Hardware]") == out.rfind("[Hardware]")); // still one block
|
|
CHECK(out.find("env = dht11\n; added ; t1, fan test\ncooling = fan\n") !=
|
|
std::string::npos); // right after its keys
|
|
CHECK(out.find("cooling = fan") < out.find("; about pins")); // before the next one's comment
|
|
auto kv = parseIniString(out);
|
|
CHECK(kv["Hardware.cooling"] == "fan");
|
|
|
|
// A section the file does not have is still appended whole.
|
|
auto out2 = updateIniSectionKeys(in, "Env", {{"i2c_device", "/dev/i2c-2"}});
|
|
CHECK(out2.find("\n[Env]\ni2c_device = /dev/i2c-2\n") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("updateIniSectionKeys keeps the superseded value as a ';prev' comment") {
|
|
const std::string in =
|
|
"[Motor]\n"
|
|
"; the heading the gimbal points at when parked\n"
|
|
"yaw_heading_at_center = 0.0\n"
|
|
"yaw_center_count = 500000\n";
|
|
|
|
auto out = updateIniSectionKeys(in, "Motor",
|
|
{{"yaw_heading_at_center", "127.4"},
|
|
{"yaw_center_count", "500000"}}, // unchanged
|
|
"2026-09-11T14:32Z, gimbal heading setup");
|
|
|
|
SUBCASE("the changed key keeps its old value, stamped") {
|
|
CHECK(out.find(";prev yaw_heading_at_center = 0.0") != std::string::npos);
|
|
CHECK(out.find("2026-09-11T14:32Z, gimbal heading setup") != std::string::npos);
|
|
CHECK(out.find("yaw_heading_at_center = 127.4") != std::string::npos);
|
|
}
|
|
SUBCASE("the history sits directly above the value it superseded") {
|
|
CHECK(out.find(";prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal "
|
|
"heading setup\nyaw_heading_at_center = 127.4") != std::string::npos);
|
|
}
|
|
SUBCASE("an UNCHANGED key gets no history - otherwise every save adds noise") {
|
|
CHECK(out.find(";prev yaw_center_count") == std::string::npos);
|
|
}
|
|
SUBCASE("hand-written comments survive") {
|
|
CHECK(out.find("; the heading the gimbal points at when parked") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("updateIniSectionKeys keeps exactly one generation of history") {
|
|
// Saving twice must REPLACE the marker, not stack markers up: the file has to
|
|
// grow once and then hold steady however often the gimbal is recalibrated.
|
|
std::string s = "[Motor]\nyaw_heading_at_center = 0.0\n";
|
|
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "10"}}, "t1, first");
|
|
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "20"}}, "t2, second");
|
|
s = updateIniSectionKeys(s, "Motor", {{"yaw_heading_at_center", "30"}}, "t3, third");
|
|
|
|
CHECK(countOccurrences(s, ";prev yaw_heading_at_center") == 1);
|
|
CHECK(s.find(";prev yaw_heading_at_center = 20") != std::string::npos); // the one before
|
|
CHECK(s.find("yaw_heading_at_center = 30") != std::string::npos);
|
|
CHECK(s.find("= 0.0") == std::string::npos); // the oldest is gone
|
|
CHECK(s.find("t3, third") != std::string::npos);
|
|
CHECK(s.find("t1, first") == std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("updateIniSectionKeys history respects inline comments and unchanged values") {
|
|
// inih treats ' ;' as an inline comment, so the stored value is "983.33", not
|
|
// "983.33 ; measured". Comparing raw text instead would call this key changed
|
|
// on every save and bury the file in useless history.
|
|
const std::string in = "[Motor]\nyaw_counts_per_deg = 983.33 ; measured on the rig\n";
|
|
|
|
SUBCASE("same value behind an inline comment counts as unchanged") {
|
|
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_counts_per_deg", "983.33"}}, "t, c");
|
|
CHECK(out.find(";prev") == std::string::npos);
|
|
}
|
|
SUBCASE("a real change records only the parsed value, not the comment") {
|
|
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_counts_per_deg", "1000"}}, "t, c");
|
|
CHECK(out.find(";prev yaw_counts_per_deg = 983.33 ; t, c") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("updateIniSectionKeys writes no history without a provenance") {
|
|
// The default keeps the old behaviour, so callers that have nothing useful to
|
|
// say about where the values came from do not litter the file.
|
|
const std::string in = "[Motor]\nyaw_center_count = 1\n";
|
|
auto out = updateIniSectionKeys(in, "Motor", {{"yaw_center_count", "2"}});
|
|
CHECK(out.find(";prev") == std::string::npos);
|
|
CHECK(out.find("yaw_center_count = 2") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("a ';prev' comment does not survive a reload as a real key") {
|
|
// The marker has to be inert to inih, or reverting by uncommenting would be
|
|
// pointless: the stale value would already be in force.
|
|
std::string s = "[General]\ntower_name = T\n[Motor]\nyaw_center_count = 1\n";
|
|
s = updateIniSectionKeys(s, "Motor", {{"yaw_center_count", "2"}}, "t, c");
|
|
REQUIRE(s.find(";prev yaw_center_count = 1") != std::string::npos);
|
|
|
|
const std::string path = "test_prev_marker_reload.ini";
|
|
{ std::ofstream(path) << s; }
|
|
auto cfg = ConfigLoader::loadFromFile(path);
|
|
CHECK(cfg.geometry.yaw.center_count == 2);
|
|
std::filesystem::remove(path);
|
|
}
|
|
|
|
TEST_CASE("saveMotorGeometry round-trips the full per-axis map") {
|
|
// Write a starting config, save a calibrated Geometry into it, reload, and
|
|
// confirm every [Motor] parameter survives, while another section is left
|
|
// untouched. min_deg/max_deg are deliberately NOT persisted - they come from
|
|
// homing - so they must come back at their defaults, not at the saved values.
|
|
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_center_count = 500000\n"
|
|
"yaw_heading_at_center = 0\n"
|
|
"pitch_counts_per_deg = 1.0\n"
|
|
"pitch_center_count = 500000\n"
|
|
"pitch_angle_at_center = 0\n";
|
|
}
|
|
|
|
Geometry geo;
|
|
geo.yaw = {983.33, 500123, 137.25, -88.0, 92.0};
|
|
geo.pitch = {8333.33, 499000, 10.5, -2.0, 61.5};
|
|
REQUIRE(saveMotorGeometry(path, geo, 4000, 6000));
|
|
|
|
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.center_count == 500123);
|
|
CHECK(cfg.geometry.yaw.deg_at_center == doctest::Approx(137.25));
|
|
CHECK(cfg.geometry.pitch.counts_per_deg == doctest::Approx(8333.33));
|
|
CHECK(cfg.geometry.pitch.center_count == 499000);
|
|
CHECK(cfg.geometry.pitch.deg_at_center == doctest::Approx(10.5));
|
|
CHECK(cfg.yaw_soft_margin_counts == 4000);
|
|
CHECK(cfg.pitch_soft_margin_counts == 6000);
|
|
|
|
std::remove(path.c_str());
|
|
}
|
|
|
|
TEST_CASE("the [Motor] loader rejects values that would silently misaim the gimbal") {
|
|
auto base = [] {
|
|
return std::map<std::string, std::string>{
|
|
{"Motor.yaw_counts_per_deg", "983.33"}, {"Motor.yaw_center_count", "500000"},
|
|
{"Motor.pitch_counts_per_deg", "8333.33"}, {"Motor.pitch_center_count", "500000"}};
|
|
};
|
|
CHECK_NOTHROW(ConfigLoader::fromMap(base()));
|
|
|
|
// A zero scale maps every degree to the centre: it would look calibrated.
|
|
auto zero_scale = base();
|
|
zero_scale["Motor.yaw_counts_per_deg"] = "0";
|
|
CHECK_THROWS_AS(ConfigLoader::fromMap(zero_scale), std::runtime_error);
|
|
|
|
// The centre count exists to keep the working range positive.
|
|
auto neg_centre = base();
|
|
neg_centre["Motor.pitch_center_count"] = "-1";
|
|
CHECK_THROWS_AS(ConfigLoader::fromMap(neg_centre), std::runtime_error);
|
|
|
|
auto neg_margin = base();
|
|
neg_margin["Motor.yaw_soft_margin_counts"] = "-100";
|
|
CHECK_THROWS_AS(ConfigLoader::fromMap(neg_margin), std::runtime_error);
|
|
}
|
|
|
|
TEST_CASE("keys from the old [Motor] layout are ignored, not silently honoured") {
|
|
// A config still carrying yaw_zero_count must load (so an upgrade is not a
|
|
// hard stop) but must not pick the stale origin up from anywhere.
|
|
std::map<std::string, std::string> kv{
|
|
{"Motor.yaw_counts_per_deg", "983.33"}, {"Motor.yaw_center_count", "500000"},
|
|
{"Motor.yaw_heading_at_center", "42"}, {"Motor.yaw_zero_count", "851592"},
|
|
{"Motor.yaw_min_deg", "0"}, {"Motor.yaw_max_deg", "319"},
|
|
{"Motor.yaw_home_zero", "high"}};
|
|
AppConfig cfg = ConfigLoader::fromMap(kv);
|
|
CHECK(cfg.geometry.yaw.center_count == 500000);
|
|
CHECK(cfg.geometry.yaw.deg_at_center == doctest::Approx(42.0));
|
|
// The removed range keys must not have leaked into the derived window.
|
|
CHECK(cfg.geometry.yaw.min_deg == doctest::Approx(-100000.0));
|
|
CHECK(cfg.geometry.yaw.max_deg == doctest::Approx(100000.0));
|
|
}
|
|
|
|
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("hardware.ini layers over config.ini and wins") {
|
|
const std::string cfg_path = "test_hw_layer_config.ini";
|
|
const std::string hw_path = "test_hw_layer_hardware.ini";
|
|
{
|
|
std::ofstream c(cfg_path);
|
|
c << "[General]\ntower_name = NorthRidge\nimage_interval = 3\n"
|
|
"[Camera.rgb]\nid = OLD_ID\nprofile_dir = /tmp/p\n"
|
|
"[Serial]\ndevice = /dev/ttyACM0\n";
|
|
std::ofstream h(hw_path);
|
|
h << "[Board]\nmodel = lattepanda-iota\nfirmware_env = iota_rp2040\n"
|
|
"[Hardware]\naxes = yaw, pitch\nimu = none\nenv = sht41\ncameras = rgb\n"
|
|
"[Serial]\ndevice = /dev/serial/by-id/usb-RP2040-if00\n"
|
|
"[Camera.rgb]\nid = NEW_ID\n";
|
|
}
|
|
auto c = ConfigLoader::loadFromFile(cfg_path, hw_path);
|
|
|
|
SUBCASE("assembly data arrives from hardware.ini") {
|
|
CHECK(c.board.model == "lattepanda-iota");
|
|
CHECK(c.board.firmware_env == "iota_rp2040");
|
|
CHECK(c.hardware.axes == std::vector<std::string>{"yaw", "pitch"});
|
|
CHECK(c.hardware.imu == "none");
|
|
CHECK(c.hardware.env == "sht41");
|
|
CHECK(c.hardware.cameras == std::vector<std::string>{"rgb"});
|
|
CHECK(c.hardware.declared);
|
|
CHECK(c.hardware.source_path == hw_path);
|
|
}
|
|
SUBCASE("bindings in hardware.ini override the ones left in config.ini") {
|
|
CHECK(c.serial.device == "/dev/serial/by-id/usb-RP2040-if00");
|
|
REQUIRE(c.camera.cameras.size() == 1);
|
|
CHECK(c.camera.cameras[0].id == "NEW_ID");
|
|
}
|
|
SUBCASE("operational settings stay with config.ini") {
|
|
CHECK(c.general.tower_name == "NorthRidge");
|
|
CHECK(c.general.image_interval == 3);
|
|
}
|
|
std::filesystem::remove(cfg_path);
|
|
std::filesystem::remove(hw_path);
|
|
}
|
|
|
|
TEST_CASE("without a hardware.ini nothing changes and nothing is declared") {
|
|
// The migration guarantee: an un-migrated tower must behave exactly as before.
|
|
const std::string path = "test_hw_absent.ini";
|
|
{
|
|
std::ofstream c(path);
|
|
c << "[General]\ntower_name = Old\n"
|
|
"[Camera.rgb]\nid = D\nprofile_dir = /tmp/p\n"
|
|
"[Serial]\ndevice = /dev/ttyACM0\n";
|
|
}
|
|
auto c = ConfigLoader::loadFromFile(path);
|
|
CHECK_FALSE(c.hardware.declared);
|
|
CHECK(c.hardware.axes.empty());
|
|
CHECK(c.serial.device == "/dev/ttyACM0");
|
|
CHECK(c.general.tower_name == "Old");
|
|
|
|
// An undeclared rig must not answer "no" to any axis, or every caller that
|
|
// asks would start refusing work on towers that have not been migrated.
|
|
CHECK(c.hardware.hasAxis("yaw"));
|
|
CHECK(c.hardware.hasAxis("pitch"));
|
|
std::filesystem::remove(path);
|
|
}
|
|
|
|
TEST_CASE("Hardware.axes parsing and membership") {
|
|
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
|
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
{"Hardware.axes", " YAW ,, pitch ,"}});
|
|
SUBCASE("case, spacing and stray commas are tolerated") {
|
|
CHECK(c.hardware.axes == std::vector<std::string>{"yaw", "pitch"});
|
|
CHECK(c.hardware.axesLabel() == "yaw, pitch");
|
|
}
|
|
SUBCASE("a declared yaw-only rig answers no to pitch") {
|
|
auto y = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
|
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
{"Hardware.axes", "yaw"}});
|
|
CHECK(y.hardware.hasAxis("yaw"));
|
|
CHECK_FALSE(y.hardware.hasAxis("pitch"));
|
|
}
|
|
SUBCASE("an unknown axis is a typo worth refusing, not ignoring") {
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
|
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
{"Hardware.axes", "yaw, roll"}}));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("[Pins] is read for comparison, and absent reads as undeclared") {
|
|
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
|
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
{"Pins.yaw_cs", "1"},
|
|
{"Pins.yaw_en", "4"},
|
|
{"Pins.spi_sck", "2"},
|
|
{"Pins.spi_miso", "0"}});
|
|
CHECK(c.hardware.pins.declared());
|
|
CHECK(c.hardware.pins.yaw_cs == 1);
|
|
CHECK(c.hardware.pins.yaw_en == 4);
|
|
CHECK(c.hardware.pins.spi_sck == 2);
|
|
CHECK(c.hardware.pins.spi_miso == 0); // GP0 is a pin, not "unset"
|
|
CHECK(c.hardware.pins.pitch_cs == -1);
|
|
|
|
auto none = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
|
|
{"Camera.rgb.profile_dir", "/tmp/p"}});
|
|
CHECK_FALSE(none.hardware.pins.declared());
|
|
}
|
|
|
|
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"));
|
|
}
|
|
|
|
TEST_CASE("[Motor.<axis>] drivetrain keys are parsed per axis") {
|
|
auto cfg = ConfigLoader::fromMap({
|
|
{"General.tower_name", "t"},
|
|
{"Motor.yaw.r_sense_ohm", "0.075"},
|
|
{"Motor.yaw.run_current_ma", "756"},
|
|
{"Motor.yaw.homing_current_ma", "200"},
|
|
{"Motor.yaw.travel_deg", "340"},
|
|
});
|
|
CHECK(cfg.drivetrain.yaw.declared);
|
|
CHECK(cfg.drivetrain.yaw.r_sense_ohm == doctest::Approx(0.075));
|
|
CHECK(cfg.drivetrain.yaw.run_current_ma == doctest::Approx(756.0));
|
|
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(200.0));
|
|
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
|
|
CHECK(cfg.drivetrain.yaw.reducesForHoming());
|
|
// The other axis stays undeclared, so nothing is sent for it.
|
|
CHECK_FALSE(cfg.drivetrain.pitch.declared);
|
|
}
|
|
|
|
TEST_CASE("an axis that names only a run current is not asking for a reduction") {
|
|
auto cfg = ConfigLoader::fromMap({
|
|
{"General.tower_name", "t"},
|
|
{"Motor.yaw.r_sense_ohm", "0.075"},
|
|
{"Motor.yaw.run_current_ma", "756"},
|
|
});
|
|
CHECK(cfg.drivetrain.yaw.homing_current_ma == doctest::Approx(756.0));
|
|
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
|
|
}
|
|
|
|
TEST_CASE("[Motor.yaw] travel_deg does not shadow [Motor] yaw_counts_per_deg") {
|
|
// The whole reason the travel angle is a separate key in a separate section:
|
|
// hardware.ini is merged OVER config.ini and wins, so if the derived
|
|
// calibration shared a key with the nameplate fact, every saved calibration
|
|
// would be silently discarded on the next start.
|
|
auto cfg = ConfigLoader::fromMap({
|
|
{"General.tower_name", "t"},
|
|
{"Motor.yaw_counts_per_deg", "3561.1"}, // calibration, from config.ini
|
|
{"Motor.yaw.travel_deg", "340"}, // nameplate, from hardware.ini
|
|
});
|
|
CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(3561.1));
|
|
CHECK(cfg.drivetrain.yaw.travel_deg == doctest::Approx(340.0));
|
|
}
|
|
|
|
TEST_CASE("a drivetrain current without its sense resistor is refused") {
|
|
// Computing registers against a guessed resistor would silently deliver the
|
|
// wrong current, which on a rig with a yielding endstop is the whole hazard.
|
|
CHECK_THROWS(ConfigLoader::fromMap({
|
|
{"General.tower_name", "t"},
|
|
{"Motor.yaw.run_current_ma", "756"},
|
|
}));
|
|
}
|
|
|
|
TEST_CASE("an undeclared drivetrain leaves every axis silent") {
|
|
auto cfg = ConfigLoader::fromMap({{"General.tower_name", "t"}});
|
|
CHECK_FALSE(cfg.drivetrain.yaw.declared);
|
|
CHECK_FALSE(cfg.drivetrain.pitch.declared);
|
|
CHECK(cfg.drivetrain.yaw.run_current_ma == 0.0);
|
|
CHECK_FALSE(cfg.drivetrain.yaw.reducesForHoming());
|
|
}
|
|
|
|
TEST_CASE("a value that only changes spelling gets no history line") {
|
|
// The writer formats doubles with %.10g, so a hand-written "0.0" comes back
|
|
// as "0". That is the same number, and recording it as a change would spend
|
|
// the single ";prev" generation on a value that never moved - displacing the
|
|
// real previous value the mechanism exists to preserve.
|
|
const std::string before = "[Motor]\nyaw_heading_at_center = 0.0\n";
|
|
const std::string after =
|
|
updateIniSectionKeys(before, "Motor", {{"yaw_heading_at_center", "0"}}, "a save");
|
|
CHECK(after.find(";prev") == std::string::npos);
|
|
CHECK(after.find("yaw_heading_at_center = 0") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("differently spelled equal numbers are not changes") {
|
|
for (auto [old_text, new_text] : std::vector<std::pair<std::string, std::string>>{
|
|
{"1e3", "1000"}, {"500.0", "500"}, {"0.50", "0.5"}, {" 12 ", "12"}}) {
|
|
const std::string out = updateIniSectionKeys("[Motor]\nk = " + old_text + "\n", "Motor",
|
|
{{"k", new_text}}, "a save");
|
|
CHECK(out.find(";prev") == std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("a genuine numeric change still records its history") {
|
|
const std::string out = updateIniSectionKeys("[Motor]\nyaw_counts_per_deg = 500\n", "Motor",
|
|
{{"yaw_counts_per_deg", "3552.867647"}},
|
|
"homed travel");
|
|
CHECK(out.find(";prev yaw_counts_per_deg = 500") != std::string::npos);
|
|
CHECK(out.find("homed travel") != std::string::npos);
|
|
CHECK(out.find("yaw_counts_per_deg = 3552.867647") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("non-numeric values still compare as text") {
|
|
const std::string out = updateIniSectionKeys("[Serial]\ndevice = /dev/ttyACM0\n", "Serial",
|
|
{{"device", "/dev/ttyUSB0"}}, "a save");
|
|
CHECK(out.find(";prev device = /dev/ttyACM0") != std::string::npos);
|
|
}
|
|
|
|
// --- [Hardware] imu/env retiring [Features] enable_imu/enable_env (plan A.12) ---
|
|
|
|
TEST_CASE("an undeclared rig still obeys the legacy flag exactly as before") {
|
|
// Every tower until it is migrated. Behaviour must not change.
|
|
auto on = resolveSensorEnable("", true, true);
|
|
CHECK(on.enabled);
|
|
CHECK(on.source == SensorEnableSource::Flag);
|
|
CHECK_FALSE(on.conflict);
|
|
|
|
auto off = resolveSensorEnable("", false, true);
|
|
CHECK_FALSE(off.enabled);
|
|
|
|
auto absent = resolveSensorEnable("", false, false);
|
|
CHECK_FALSE(absent.enabled);
|
|
CHECK(absent.source == SensorEnableSource::Default);
|
|
}
|
|
|
|
TEST_CASE("a declared sensor needs no second key agreeing with it") {
|
|
// The trap this removes: declare the IMU, forget enable_imu, and presence
|
|
// reconciliation reports missing HARDWARE for what is a missing flag.
|
|
auto e = resolveSensorEnable("mti-630", /*flag_value=*/false, /*flag_present=*/false);
|
|
CHECK(e.enabled);
|
|
CHECK(e.source == SensorEnableSource::Declaration);
|
|
CHECK_FALSE(e.conflict);
|
|
}
|
|
|
|
TEST_CASE("'none' cannot be contradicted into enabling a sensor") {
|
|
auto e = resolveSensorEnable("none", /*flag_value=*/true, /*flag_present=*/true);
|
|
CHECK_FALSE(e.enabled);
|
|
CHECK(e.source == SensorEnableSource::Declaration);
|
|
CHECK(e.conflict); // named, so the stale key can be deleted
|
|
}
|
|
|
|
TEST_CASE("a declaration beats a flag that disagrees, and the clash is reported") {
|
|
auto e = resolveSensorEnable("sht41", /*flag_value=*/false, /*flag_present=*/true);
|
|
CHECK(e.enabled);
|
|
CHECK(e.conflict);
|
|
|
|
// Agreeing is not a conflict, merely redundant.
|
|
auto agree = resolveSensorEnable("sht41", true, true);
|
|
CHECK(agree.enabled);
|
|
CHECK_FALSE(agree.conflict);
|
|
}
|
|
|
|
TEST_CASE("image saving and de-Bayer settings: defaults, sharing the cores, limits") {
|
|
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 ConfigLoader::fromMap(kv);
|
|
};
|
|
|
|
AppConfig c = base({});
|
|
CHECK(c.hardware.save_workers == 1);
|
|
CHECK(c.hardware.jxl_threads == 4); // 4 / save_workers
|
|
CHECK(c.hardware.save_queue_max == 4);
|
|
REQUIRE(c.camera.cameras.size() == 1);
|
|
CHECK(c.camera.cameras[0].demosaic == "edge_aware");
|
|
|
|
// Two workers share the four cores unless told otherwise (the 3 Delta).
|
|
c = base({{"Hardware.save_workers", "2"}});
|
|
CHECK(c.hardware.jxl_threads == 2);
|
|
c = base({{"Hardware.save_workers", "2"}, {"Hardware.jxl_threads", "3"}});
|
|
CHECK(c.hardware.jxl_threads == 3);
|
|
|
|
// [Camera] default, overridden per camera; case does not matter.
|
|
c = base({{"Camera.demosaic", "Bilinear"}});
|
|
CHECK(c.camera.cameras[0].demosaic == "bilinear");
|
|
c = base({{"Camera.demosaic", "bilinear"}, {"Camera.rgb.demosaic", "edge_aware"}});
|
|
CHECK(c.camera.cameras[0].demosaic == "edge_aware");
|
|
|
|
CHECK_THROWS(base({{"Camera.rgb.demosaic", "vng"}}));
|
|
CHECK_THROWS(base({{"Hardware.save_workers", "0"}}));
|
|
CHECK_THROWS(base({{"Hardware.save_workers", "5"}}));
|
|
CHECK_THROWS(base({{"Hardware.jxl_threads", "9"}}));
|
|
CHECK_THROWS(base({{"Hardware.save_queue_max", "0"}}));
|
|
CHECK_THROWS(base({{"Hardware.save_queue_max", "33"}}));
|
|
}
|
|
|
|
TEST_CASE("each profile chooses its keep rule: fixed (default) or brightness") {
|
|
auto c = ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"},
|
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
{"Camera.rgb.standard.settle_rule", "Brightness"},
|
|
{"Camera.rgb.standard.frame_timeout_ms", "5000"},
|
|
{"Camera.rgb.night.settle_wait_ms", "2500"}});
|
|
REQUIRE(c.camera.cameras.size() == 1);
|
|
const auto& cam = c.camera.cameras[0];
|
|
CHECK(cam.timingFor("standard").settle_rule == "brightness");
|
|
CHECK(cam.timingFor("standard").frame_timeout_ms == 5000);
|
|
CHECK(cam.timingFor("night").settle_rule == "fixed");
|
|
CHECK(cam.timingFor("unnamed").settle_rule == "fixed");
|
|
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"},
|
|
{"Camera.rgb.profile_dir", "/tmp/p"},
|
|
{"Camera.rgb.standard.settle_rule", "magic"}}));
|
|
}
|