fwt_software/tests/test_remotecommands.cpp

224 lines
8.7 KiB
C++

#include <doctest/doctest.h>
#include "fgc/ChannelTopics.h"
#include "fgc/Logger.h"
#include "fgc/RemoteCommands.h"
#include "fgc/mock/NullControlChannel.h"
#include <string>
#include <thread>
using namespace fgc;
// --- the Command payload ---------------------------------------------------------
TEST_CASE("a JSON command carries its id and command line") {
auto rc = parseCommandPayload(R"({"id":"web-42","cmd":"gimbal move 45,10"})");
CHECK(rc.error.empty());
CHECK(rc.id == "web-42");
CHECK(rc.cmd == "gimbal move 45,10");
}
TEST_CASE("a bare line is a command with no id - for mosquitto_pub from a shell") {
auto rc = parseCommandPayload(" gimbal status\n");
CHECK(rc.error.empty());
CHECK(rc.id.empty());
CHECK(rc.cmd == "gimbal status");
}
TEST_CASE("JSON escapes are honoured, and unknown keys are ignored") {
auto rc = parseCommandPayload(
R"({ "user": "maint", "n": 3, "cmd": "gimbal raw \"STATUS\"", "id": "aé" })");
CHECK(rc.error.empty());
CHECK(rc.cmd == "gimbal raw \"STATUS\"");
CHECK(rc.id == "a\xC3\xA9");
}
TEST_CASE("unreadable or empty payloads are errors, never commands") {
CHECK_FALSE(parseCommandPayload("").error.empty());
CHECK_FALSE(parseCommandPayload("{\"id\":\"x\"}").error.empty()); // no cmd
CHECK_FALSE(parseCommandPayload("{\"cmd\":42}").error.empty()); // not a string
CHECK_FALSE(parseCommandPayload("{\"cmd\":\"start\"").error.empty()); // unterminated
CHECK_FALSE(parseCommandPayload("start\nexit").error.empty()); // two commands
}
// --- the CommandReply payload -----------------------------------------------------
TEST_CASE("the reply is valid JSON, whatever the log lines contain") {
CommandReply r;
r.id = "7"; r.cmd = "help"; r.level = "warn";
r.lines = {"a \"quoted\" word", "back\\slash", "tab\there"};
const std::string j = commandReplyJson(r);
CHECK(j == "{\"id\":\"7\",\"cmd\":\"help\",\"level\":\"warn\",\"lines\":"
"[\"a \\\"quoted\\\" word\",\"back\\\\slash\",\"tab\\there\"],\"truncated\":false}");
}
TEST_CASE("a long answer is cut, and says so") {
CommandReply r;
r.lines.assign(kReplyMaxLines + 5, "x");
const std::string j = commandReplyJson(r);
CHECK(j.find("\"truncated\":true") != std::string::npos);
}
// --- running it -----------------------------------------------------------------
TEST_CASE("a remote command runs through the handler and is answered with what it logged") {
RemoteCommandRunner run;
RemoteCommand rc; rc.id = "1"; rc.cmd = "help";
std::string seen;
auto reply = run.run(rc, [&](const std::string& line) {
seen = line;
LOG_INFO << "first line of help";
LOG_INFO << "second line of help";
});
CHECK(seen == "help");
CHECK(reply->id == "1");
CHECK(reply->level == "info");
REQUIRE(reply->lines.size() == 2);
CHECK(reply->lines[1].find("second line of help") != std::string::npos);
}
TEST_CASE("the level is the most severe line: a refusal reads as warn") {
RemoteCommandRunner run;
RemoteCommand rc; rc.id = "2"; rc.cmd = "gimbal move 10,0";
auto reply = run.run(rc, [](const std::string&) {
LOG_WARN << "gimbal move refused while a scan is running";
});
CHECK(reply->level == "warn");
RemoteCommand bad; bad.id = "3"; bad.cmd = "camera reset";
CHECK(run.run(bad, [](const std::string&) { LOG_ERROR << "reset FAILED"; })->level == "error");
}
TEST_CASE("a retained or unreadable command is answered but never run") {
RemoteCommandRunner run;
int ran = 0;
RemoteCommand retained; retained.cmd = "mode auto"; retained.retained = true;
auto r1 = run.run(retained, [&](const std::string&) { ++ran; });
CHECK(ran == 0);
CHECK(r1->level == "error");
CHECK(r1->lines.at(0).find("retained") != std::string::npos);
RemoteCommand garbled; garbled.error = "unreadable JSON";
auto r2 = run.run(garbled, [&](const std::string&) { ++ran; });
CHECK(ran == 0);
CHECK(r2->level == "error");
}
TEST_CASE("a repeated id is answered again, not run again") {
RemoteCommandRunner run;
int ran = 0;
RemoteCommand rc; rc.id = "dup"; rc.cmd = "snapshot";
auto h = [&](const std::string&) { ++ran; LOG_INFO << "snap " << ran; };
auto first = run.run(rc, h);
auto second = run.run(rc, h);
CHECK(ran == 1);
CHECK(second->lines == first->lines);
// Without an id nothing can be recognised as a repeat: it runs each time.
RemoteCommand bare; bare.cmd = "snapshot";
run.run(bare, h);
run.run(bare, h);
CHECK(ran == 3);
}
TEST_CASE("the oldest remembered id ages out") {
RemoteCommandRunner run;
int ran = 0;
auto h = [&](const std::string&) { ++ran; };
RemoteCommand first; first.id = "id-0"; first.cmd = "x";
run.run(first, h);
for (size_t i = 1; i <= RemoteCommandRunner::kRememberIds; ++i) {
RemoteCommand rc; rc.id = "id-" + std::to_string(i); rc.cmd = "x";
run.run(rc, h);
}
run.run(first, h); // forgotten by now: runs again
CHECK(ran == static_cast<int>(RemoteCommandRunner::kRememberIds) + 2);
}
TEST_CASE("lines logged by other threads meanwhile are not part of the answer") {
RemoteCommandRunner run;
RemoteCommand rc; rc.id = "t"; rc.cmd = "gimbal status";
auto reply = run.run(rc, [](const std::string&) {
std::thread other([] { LOG_INFO << "camera: frame from another thread"; });
other.join();
LOG_INFO << "status from the command itself";
});
REQUIRE(reply->lines.size() == 1);
CHECK(reply->lines[0].find("status from the command itself") != std::string::npos);
}
// --- an answer the firmware sends later ---------------------------------------
namespace {
const Await kDump{{"firmware dump", "Motor controller:"},
{"firmware dump:", "firmware dump still incomplete"}, 5000};
}
TEST_CASE("a dump's reply waits for the dump, logged later on another thread") {
RemoteCommandRunner run;
RemoteCommand rc; rc.id = "d"; rc.cmd = "gimbal dump";
auto now = run.run(rc, [](const std::string&) { LOG_INFO << "requesting firmware dump..."; },
&kDump, 1000);
CHECK_FALSE(now);
CHECK(run.waiting());
CHECK_FALSE(run.poll(2000)); // not arrived yet
std::thread serial([] {
LOG_INFO << "camera: an unrelated line";
LOG_WARN << "firmware dump incomplete (lossy read); re-requesting (7 attempt(s) left)";
LOG_INFO << "firmware dump:\nDUMP BEGIN\nDUMP Y state=1\nDUMP END\n";
});
serial.join();
LOG_INFO << "sweep: a control-thread line after the command returned";
auto reply = run.poll(2500);
REQUIRE(reply);
CHECK_FALSE(run.waiting());
CHECK(reply->level == "warn"); // the lossy read is part of the answer
REQUIRE(reply->lines.size() == 6);
CHECK(reply->lines[0].find("requesting firmware dump") != std::string::npos);
CHECK(reply->lines[1].find("re-requesting") != std::string::npos);
CHECK(reply->lines[2].find("firmware dump:") != std::string::npos);
CHECK(reply->lines[3] == "DUMP BEGIN");
CHECK(reply->lines[5] == "DUMP END");
}
TEST_CASE("a dump that never comes is answered when the wait runs out") {
RemoteCommandRunner run;
RemoteCommand rc; rc.id = "d2"; rc.cmd = "gimbal dump";
CHECK_FALSE(run.run(rc, [](const std::string&) {}, &kDump, 1000));
CHECK_FALSE(run.poll(5999));
auto reply = run.poll(6000);
REQUIRE(reply);
CHECK(reply->level == "warn");
CHECK(reply->lines.back().find("no answer from the firmware") != std::string::npos);
// ...and remembered like any other: a repeat is answered, not re-sent.
int ran = 0;
auto again = run.run(rc, [&](const std::string&) { ++ran; }, &kDump, 7000);
REQUIRE(again);
CHECK(ran == 0);
}
TEST_CASE("a refused command sent nothing, so its reply does not wait") {
RemoteCommandRunner run;
RemoteCommand rc; rc.id = "d3"; rc.cmd = "gimbal dump";
auto reply = run.run(rc, [](const std::string&) { LOG_WARN << "ignored: a test is in progress"; },
&kDump, 1000);
REQUIRE(reply);
CHECK_FALSE(run.waiting());
CHECK(reply->level == "warn");
}
TEST_CASE("the null channel carries an injected command through to a recorded reply") {
NullControlChannel ch("dev");
RemoteCommand rc; rc.id = "n1"; rc.cmd = "help";
ch.inject(rc);
auto got = ch.takeCommands();
REQUIRE(got.size() == 1);
CHECK(ch.takeCommands().empty());
CommandReply r; r.id = "n1"; r.cmd = "help";
ch.publishCommandReply(r);
REQUIRE(ch.replies().size() == 1);
const auto recent = ch.recentMessages();
REQUIRE(recent.size() == 2);
CHECK(recent[0].topic == "GGS/FWT/dev/Command");
CHECK(recent[1].topic == "GGS/FWT/dev/CommandReply");
}