#include #include "fgc/ChannelTopics.h" #include "fgc/Logger.h" #include "fgc/RemoteCommands.h" #include "fgc/mock/NullControlChannel.h" #include #include 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 = "start"; 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(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"); }