224 lines
8.7 KiB
C++
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");
|
|
}
|