Files
open-bamboo-networking/tests/agent_test.cpp
T
2026-09-18 18:08:11 +02:00

468 lines
16 KiB
C++

// Tests for agent.cpp — covers SSDP bind-detect lookup, firmware JSON
// rendering, and synthetic subtask id minting.
// All tests run entirely in-process with no network connections.
#include "obn/agent.hpp"
#include "obn/bambu_networking.hpp"
#include "obn/json_lite.hpp"
#include <cstdio>
#include <filesystem>
#include <string>
#include <vector>
static int fail_count = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "FAIL %s:%d: %s\n", \
__FILE__, __LINE__, #cond); \
++fail_count; \
} \
} while (0)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Minimal SSDP alive JSON that cache_ssdp_json_for_bind() understands.
static std::string ssdp_alive(const std::string& ip,
const std::string& dev_id,
const std::string& dev_type,
const std::string& dev_name = "TestPrinter")
{
return R"({"dev_ip":")" + ip +
R"(","dev_id":")" + dev_id +
R"(","dev_type":")" + dev_type +
R"(","dev_name":")" + dev_name + R"("})";
}
// Inject a JSON string through notify_local_message and return the (possibly
// patched) payload that the on_local_message_ callback received.
static std::string run_local_msg(obn::Agent& a,
const std::string& dev_id,
const std::string& json)
{
std::string out;
a.set_on_local_message_fn([&out](std::string, std::string j) {
out = std::move(j);
});
a.notify_local_message(dev_id, json);
a.set_on_local_message_fn(nullptr);
return out;
}
// ---------------------------------------------------------------------------
// SSDP bind-detect lookup
// ---------------------------------------------------------------------------
static void test_lookup_bind_detect_success()
{
obn::Agent a(".");
a.cache_ssdp_json_for_bind(
ssdp_alive("192.168.1.20", "DEV010", "3DPrinter-X1C", "My X1 Carbon"));
BBL::detectResult out{};
int rc = a.lookup_bind_detect("192.168.1.20", out, /*wait_ms=*/0);
CHECK(rc == 0);
CHECK(out.dev_id == "DEV010");
CHECK(out.model_id == "3DPrinter-X1C");
CHECK(out.dev_name == "My X1 Carbon");
CHECK(out.command == "bind_detect");
CHECK(out.result_msg == "ok");
}
static void test_lookup_bind_detect_timeout_when_no_cache()
{
obn::Agent a(".");
BBL::detectResult out{};
int rc = a.lookup_bind_detect("10.0.0.99", out, /*wait_ms=*/0);
CHECK(rc == -3);
}
static void test_lookup_bind_detect_trims_ip_whitespace()
{
obn::Agent a(".");
a.cache_ssdp_json_for_bind(ssdp_alive("192.168.1.21", "DEV011", "3DPrinter-A1"));
BBL::detectResult out{};
// Surrounding whitespace and a trailing newline on the query IP.
int rc = a.lookup_bind_detect(" 192.168.1.21\n", out, 0);
CHECK(rc == 0);
CHECK(out.dev_id == "DEV011");
}
static void test_device_display_name_from_ssdp()
{
obn::Agent a(".");
a.cache_ssdp_json_for_bind(
ssdp_alive("192.168.1.22", "DEV012", "3DPrinter-P2S", "Studio P2S"));
CHECK(a.device_display_name_for_ip("192.168.1.22") == "Studio P2S");
// Unknown IP: empty.
CHECK(a.device_display_name_for_ip("192.168.1.99") == "");
}
// ---------------------------------------------------------------------------
// Firmware JSON rendering via notify_local_message
// ---------------------------------------------------------------------------
static void test_render_fw_empty_device()
{
obn::Agent a(".");
const std::string fw = a.render_firmware_json("unknown_dev");
// Must be valid JSON with the expected outer structure.
CHECK(fw.find("\"devices\"") != std::string::npos);
CHECK(fw.find("\"firmware\"") != std::string::npos);
// No modules → firmware array is empty.
CHECK(fw.find("\"firmware\":[]") != std::string::npos);
}
static void test_render_fw_from_get_version()
{
obn::Agent a(".");
const std::string msg = R"({
"info":{
"command":"get_version",
"module":[
{"name":"ota","sw_ver":"01.08.01.00","product_name":"P2S"},
{"name":"ams/0","sw_ver":"00.00.06.49"}
]
}
})";
run_local_msg(a, "dev_gv", msg);
const std::string fw = a.render_firmware_json("dev_gv");
CHECK(fw.find("01.08.01.00") != std::string::npos);
CHECK(fw.find("00.00.06.49") != std::string::npos);
}
static void test_render_fw_new_ver_from_upgrade_state()
{
obn::Agent a(".");
// Seed current version via get_version.
run_local_msg(a, "dev_up", R"({
"info":{"command":"get_version","module":[
{"name":"ota","sw_ver":"01.08.01.00","product_name":"X1-Carbon"}
]}
})");
// A push from upgrade_state with a newer version available.
run_local_msg(a, "dev_up", R"({
"print":{"upgrade_state":{"new_ver_list":[
{"name":"ota","cur_ver":"01.08.01.00","new_ver":"01.09.01.00"}
]}}
})");
const std::string fw = a.render_firmware_json("dev_up");
CHECK(fw.find("01.08.01.00") != std::string::npos);
CHECK(fw.find("01.09.01.00") != std::string::npos);
}
static void test_render_fw_no_extra_entry_when_ver_equal()
{
obn::Agent a(".");
// sw_ver == sw_new_ver: the new-version entry must NOT be emitted.
run_local_msg(a, "dev_eq", R"({
"info":{"command":"get_version","module":[
{"name":"ota","sw_ver":"01.08.01.00",
"sw_new_ver":"01.08.01.00","product_name":"A1"}
]}
})");
const std::string fw = a.render_firmware_json("dev_eq");
// "01.08.01.00" should appear exactly once (the cur entry), not twice.
std::size_t count = 0;
for (std::size_t p = 0;
(p = fw.find("01.08.01.00", p)) != std::string::npos;
++p) {
++count;
}
CHECK(count == 1);
}
// ---------------------------------------------------------------------------
// Synthetic subtask minting (push_status id-rewriting)
// ---------------------------------------------------------------------------
static void test_synthetic_subtask_created_for_zero_ids()
{
obn::Agent a(".");
const std::string payload = R"({
"print":{
"subtask_name":"bunny.gcode.3mf",
"task_id":"0",
"subtask_id":"0",
"project_id":"0",
"profile_id":"0",
"gcode_start_time":"1700000001"
}
})";
const std::string out = run_local_msg(a, "dev_sp", payload);
CHECK(!out.empty());
// The rewritten subtask_id must have the "lan-" prefix.
auto parsed = obn::json::parse(out);
CHECK(parsed);
const std::string synth_id = parsed->find("print.subtask_id").as_string();
CHECK(synth_id.rfind("lan-", 0) == 0);
// The agent must be able to resolve the id back to the original name.
obn::Agent::SubtaskCoverInfo info{};
CHECK(a.lookup_synthetic_subtask(synth_id, &info));
CHECK(info.subtask_name == "bunny.gcode.3mf");
CHECK(info.plate_idx == 1);
}
static void test_synthetic_subtask_not_created_without_name()
{
obn::Agent a(".");
// No subtask_name in the payload: rewrite must not fire.
run_local_msg(a, "dev_nn", R"({
"print":{"task_id":"0","subtask_id":"0"}
})");
obn::Agent::SubtaskCoverInfo info{};
CHECK(!a.lookup_synthetic_subtask("lan-00000000", &info));
}
static void test_synthetic_subtask_not_created_for_sentinel_name()
{
obn::Agent a(".");
// subtask_name == "-1" is a sentinel meaning "no active print".
run_local_msg(a, "dev_sn", R"({
"print":{"subtask_name":"-1","task_id":"0","subtask_id":"0",
"project_id":"0","profile_id":"0"}
})");
obn::Agent::SubtaskCoverInfo info{};
CHECK(!a.lookup_synthetic_subtask("lan-00000000", &info));
}
static void test_synthetic_subtask_id_is_deterministic()
{
// Same (subtask_name, gcode_start_time) must yield the same id regardless
// of which Agent instance produces it.
auto get_id = [](const std::string& name, const std::string& ts) {
obn::Agent a(".");
const std::string payload =
R"({"print":{"subtask_name":")" + name +
R"(","task_id":"0","subtask_id":"0","project_id":"0",)"
R"("profile_id":"0","gcode_start_time":")" + ts + R"("}})";
const std::string out = run_local_msg(a, "dev", payload);
auto parsed = obn::json::parse(out);
return parsed ? parsed->find("print.subtask_id").as_string() : std::string{};
};
const std::string id1 = get_id("rocket.gcode.3mf", "1700001234");
const std::string id2 = get_id("rocket.gcode.3mf", "1700001234");
CHECK(!id1.empty());
CHECK(id1 == id2);
}
static void test_synthetic_subtask_id_varies_with_timestamp()
{
// Different gcode_start_time must produce a different id so that
// a same-named re-print invalidates the cover cache.
auto get_id = [](const std::string& ts) {
obn::Agent a(".");
const std::string payload = std::string(
R"({"print":{"subtask_name":"boat.gcode.3mf","task_id":"0",)"
R"("subtask_id":"0","project_id":"0","profile_id":"0",)"
R"("gcode_start_time":")") + ts + R"("}})";
const std::string out = run_local_msg(a, "dev", payload);
auto parsed = obn::json::parse(out);
return parsed ? parsed->find("print.subtask_id").as_string() : std::string{};
};
const std::string id1 = get_id("1700000001");
const std::string id2 = get_id("1700000002");
CHECK(!id1.empty());
CHECK(!id2.empty());
CHECK(id1 != id2);
}
static void test_cloud_subtask_id_is_not_mapped_to_local_cover()
{
// Cloud / "print with record" already has a real task id and an S3
// cover. We must not register it as a synthetic lan cover, or
// get_subtask_info would intercept the cloud fetch.
obn::Agent a(".");
run_local_msg(a, "dev_cloud", R"({
"print":{
"subtask_name":"SwitchMount-Test_plate_1",
"task_id":"1261510494",
"subtask_id":"1261510494",
"project_id":"1024742067",
"profile_id":"1008100615"
}
})");
obn::Agent::SubtaskCoverInfo info{};
CHECK(!a.lookup_synthetic_subtask("1261510494", &info));
}
// ---------------------------------------------------------------------------
// Selected-printer memory (issue #78)
// ---------------------------------------------------------------------------
// Fresh, empty config dir; each caller gets its own so the state file from one
// test can't leak into the next.
static std::filesystem::path fresh_config_dir(const char* tag)
{
const std::filesystem::path dir =
std::filesystem::temp_directory_path() /
(std::string("obn_agent_test_") + tag);
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir, ec);
return dir;
}
static void test_selection_survives_agent_restart()
{
const std::filesystem::path dir = fresh_config_dir("restart");
{
obn::Agent a(".");
a.set_config_dir(dir.string());
CHECK(a.user_selected_machine() == "");
a.set_user_selected_machine("00M00A000000001");
CHECK(a.user_selected_machine() == "00M00A000000001");
}
{
obn::Agent restarted(".");
restarted.set_config_dir(dir.string());
CHECK(restarted.user_selected_machine() == "00M00A000000001");
}
std::error_code ec;
std::filesystem::remove_all(dir, ec);
}
static void test_deselect_keeps_remembered_printer()
{
const std::filesystem::path dir = fresh_config_dir("deselect");
{
obn::Agent a(".");
a.set_config_dir(dir.string());
a.set_user_selected_machine("00M00A000000001");
// Orca deselects on logout, on its 5 s refresh tick when the printer
// is momentarily missing, and while installing its printer agent at
// startup. None of that means the user gave up the printer, and the
// plugin's copy is the last one standing.
a.set_user_selected_machine("");
CHECK(a.user_selected_machine() == "00M00A000000001");
}
{
obn::Agent restarted(".");
restarted.set_config_dir(dir.string());
CHECK(restarted.user_selected_machine() == "00M00A000000001");
}
std::error_code ec;
std::filesystem::remove_all(dir, ec);
}
static void test_live_selection_replaces_remembered_printer()
{
const std::filesystem::path dir = fresh_config_dir("switch");
{
obn::Agent a(".");
a.set_config_dir(dir.string());
a.set_user_selected_machine("00M00A000000001");
}
{
obn::Agent a(".");
a.set_config_dir(dir.string());
a.set_user_selected_machine("00M00A000000002");
CHECK(a.user_selected_machine() == "00M00A000000002");
}
{
obn::Agent restarted(".");
restarted.set_config_dir(dir.string());
CHECK(restarted.user_selected_machine() == "00M00A000000002");
}
std::error_code ec;
std::filesystem::remove_all(dir, ec);
}
static void test_selection_without_config_dir_is_session_only()
{
// Studio can select a printer before it tells us where to keep state; that
// must not crash, and must not resurrect an older id either.
obn::Agent a(".");
a.set_user_selected_machine("00M00A000000003");
CHECK(a.user_selected_machine() == "00M00A000000003");
}
static void test_selection_before_config_dir_is_persisted()
{
// Studio can select a printer before it hands us the config dir. That
// live choice must survive, and must outrank whatever the previous
// session left on disk.
const std::filesystem::path dir = fresh_config_dir("early");
{
obn::Agent seed(".");
seed.set_config_dir(dir.string());
seed.set_user_selected_machine("00M00A000000005");
}
{
obn::Agent a(".");
a.set_user_selected_machine("00M00A000000006");
a.set_config_dir(dir.string());
CHECK(a.user_selected_machine() == "00M00A000000006");
}
{
obn::Agent restarted(".");
restarted.set_config_dir(dir.string());
CHECK(restarted.user_selected_machine() == "00M00A000000006");
}
std::error_code ec;
std::filesystem::remove_all(dir, ec);
}
static void test_replayed_config_dir_keeps_store()
{
// Orca calls set_config_dir twice on the same handle. The second call
// must not drop a selection that landed between them.
const std::filesystem::path dir = fresh_config_dir("replay");
obn::Agent a(".");
a.set_config_dir(dir.string());
a.set_user_selected_machine("00M00A000000004");
a.set_config_dir(dir.string());
CHECK(a.user_selected_machine() == "00M00A000000004");
std::error_code ec;
std::filesystem::remove_all(dir, ec);
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
int main()
{
// Bind-detect lookup.
test_lookup_bind_detect_success();
test_lookup_bind_detect_timeout_when_no_cache();
test_lookup_bind_detect_trims_ip_whitespace();
test_device_display_name_from_ssdp();
// Firmware JSON rendering.
test_render_fw_empty_device();
test_render_fw_from_get_version();
test_render_fw_new_ver_from_upgrade_state();
test_render_fw_no_extra_entry_when_ver_equal();
// Synthetic subtask id minting.
test_synthetic_subtask_created_for_zero_ids();
test_synthetic_subtask_not_created_without_name();
test_synthetic_subtask_not_created_for_sentinel_name();
test_synthetic_subtask_id_is_deterministic();
test_synthetic_subtask_id_varies_with_timestamp();
test_cloud_subtask_id_is_not_mapped_to_local_cover();
// Selected-printer memory.
test_selection_survives_agent_restart();
test_deselect_keeps_remembered_printer();
test_live_selection_replaces_remembered_printer();
test_selection_without_config_dir_is_session_only();
test_selection_before_config_dir_is_persisted();
test_replayed_config_dir_keeps_store();
if (fail_count) {
std::fprintf(stderr, "%d test(s) failed\n", fail_count);
return 1;
}
std::fprintf(stdout, "agent_test: ok\n");
return 0;
}