fwt_software/tests/test_logger.cpp

247 lines
8.7 KiB
C++

#include <doctest/doctest.h>
#include "fgc/Logger.h"
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
using namespace fgc;
namespace {
// Run a callable with std::cout redirected to a buffer; return what was written.
template <typename F>
std::string captureCout(F&& f) {
std::ostringstream oss;
std::streambuf* old = std::cout.rdbuf(oss.rdbuf());
f();
std::cout.rdbuf(old);
return oss.str();
}
unsigned bits(LogCat c) { return static_cast<unsigned>(c); }
} // namespace
TEST_CASE("parseCategories maps names, all/none, and flags unknown tokens") {
bool ok = true;
CHECK(Logger::parseCategories("serial,mqtt", &ok) == (bits(LogCat::Serial) | bits(LogCat::Mqtt)));
CHECK(ok);
CHECK(Logger::parseCategories("all", &ok) == bits(LogCat::All));
CHECK(ok);
CHECK(Logger::parseCategories("none", &ok) == 0u);
CHECK(ok);
CHECK(Logger::parseCategories("", &ok) == 0u);
CHECK(ok);
// Case- and whitespace-insensitive.
CHECK(Logger::parseCategories(" SeRiAl , Camera ", &ok)
== (bits(LogCat::Serial) | bits(LogCat::Camera)));
CHECK(ok);
// Unknown token: ok=false, but recognized tokens still apply.
ok = true;
CHECK(Logger::parseCategories("serial,bogus", &ok) == bits(LogCat::Serial));
CHECK_FALSE(ok);
}
TEST_CASE("catFromString round-trips and flags unknown") {
bool ok = true;
CHECK(Logger::catFromString("control", &ok) == LogCat::Control);
CHECK(ok);
CHECK(Logger::catFromString("nope", &ok) == LogCat::None);
CHECK_FALSE(ok);
}
TEST_CASE("category enable/disable/set round-trip") {
Logger::setCategories(0);
CHECK(Logger::categories() == 0u);
CHECK_FALSE(Logger::categoryEnabled(LogCat::Serial));
Logger::enableCategory(LogCat::Serial);
Logger::enableCategory(LogCat::Mqtt);
CHECK(Logger::categoryEnabled(LogCat::Serial));
CHECK(Logger::categoryEnabled(LogCat::Mqtt));
CHECK_FALSE(Logger::categoryEnabled(LogCat::Camera));
Logger::disableCategory(LogCat::Serial);
CHECK_FALSE(Logger::categoryEnabled(LogCat::Serial));
CHECK(Logger::categoryEnabled(LogCat::Mqtt));
Logger::setCategories(bits(LogCat::All));
CHECK(Logger::categoryEnabled(LogCat::Camera));
CHECK(Logger::categoryEnabled(LogCat::Control));
Logger::setCategories(0); // leave global state clean for other tests
}
TEST_CASE("LOG_TRACE_CAT gating is independent of the linear level") {
Logger::setCategories(0);
SUBCASE("suppressed when category off (even at trace level)") {
Logger::setLevel(LogLevel::Trace);
std::string out = captureCout([] { LOG_TRACE_CAT(LogCat::Serial) << "TX kd180"; });
CHECK(out.empty());
}
SUBCASE("emitted with [SERIAL] tag when category on, at default info level") {
Logger::setLevel(LogLevel::Info); // NOT trace
Logger::enableCategory(LogCat::Serial);
std::string out = captureCout([] { LOG_TRACE_CAT(LogCat::Serial) << "TX kd180"; });
CHECK(out.find("[SERIAL]") != std::string::npos);
CHECK(out.find("TX kd180") != std::string::npos);
}
SUBCASE("a different category stays silent") {
Logger::setLevel(LogLevel::Info);
Logger::enableCategory(LogCat::Serial); // serial on, mqtt off
std::string out = captureCout([] { LOG_TRACE_CAT(LogCat::Mqtt) << "PUB t p"; });
CHECK(out.empty());
}
SUBCASE("level Off silences categories too") {
Logger::setLevel(LogLevel::Off);
Logger::enableCategory(LogCat::Serial);
std::string out = captureCout([] { LOG_TRACE_CAT(LogCat::Serial) << "TX x"; });
CHECK(out.empty());
}
// Restore defaults so test ordering can't leak state.
Logger::setCategories(0);
Logger::setLevel(LogLevel::Info);
}
// --- the file sink: a second, independent destination ----------------------
namespace {
// Logger state is process-global and doctest runs every case in one process, so
// a case that leaks a file sink makes LATER cases write into a deleted temp dir.
// Restoring at the end of the body is not enough: a failed REQUIRE skips it.
struct LoggerStateGuard {
fgc::LogLevel console = fgc::Logger::level();
fgc::LogLevel file = fgc::Logger::fileLevel();
unsigned cats = fgc::Logger::categories();
~LoggerStateGuard() {
fgc::Logger::setFileSink({});
fgc::Logger::setLevel(console);
fgc::Logger::setFileLevel(file);
fgc::Logger::setCategories(cats);
}
};
// Collects what the file sink is handed.
struct Captured {
std::vector<std::string> lines;
std::vector<fgc::LogCat> cats;
bool has(const std::string& needle) const {
for (const auto& l : lines)
if (l.find(needle) != std::string::npos) return true;
return false;
}
};
} // namespace
TEST_CASE("the file is more verbose than the console, independently") {
LoggerStateGuard guard;
Captured cap;
Logger::setFileSink([&](LogLevel, LogCat c, const std::string& l) {
cap.lines.push_back(l);
cap.cats.push_back(c);
});
Logger::setLevel(LogLevel::Info); // console
Logger::setFileLevel(LogLevel::Debug); // file
// The macros must gate on the MORE verbose of the two, or the debug line
// would never be built at all.
CHECK(Logger::effectiveLevel() == LogLevel::Debug);
CHECK(Logger::enabled(LogLevel::Debug));
const std::string out = captureCout([] {
LOG_DEBUG << "detail-for-file";
LOG_INFO << "for-both";
});
CHECK(cap.has("detail-for-file")); // file got it
CHECK(out.find("detail-for-file") == std::string::npos); // console did not
CHECK(cap.has("for-both"));
CHECK(out.find("for-both") != std::string::npos);
}
TEST_CASE("a wire trace reaches the file even though its level is below the threshold") {
// The regression this design is most likely to introduce. A category line
// carries level == Trace; filtering it against file_level (debug) would drop
// every wire trace silently, and `--trace serial` would write nothing.
LoggerStateGuard guard;
Captured cap;
Logger::setFileSink([&](LogLevel, LogCat c, const std::string& l) {
cap.lines.push_back(l);
cap.cats.push_back(c);
});
Logger::setLevel(LogLevel::Info);
Logger::setFileLevel(LogLevel::Debug);
SUBCASE("category off: nowhere") {
Logger::setCategories(0);
const std::string out = captureCout([] { LOG_TRACE_CAT(LogCat::Serial) << "TX ping"; });
CHECK_FALSE(cap.has("TX ping"));
CHECK(out.find("TX ping") == std::string::npos);
}
SUBCASE("category on: both, and the file is told which category") {
Logger::setCategories(static_cast<unsigned>(LogCat::Serial));
const std::string out = captureCout([] { LOG_TRACE_CAT(LogCat::Serial) << "TX ping"; });
CHECK(cap.has("TX ping"));
CHECK(cap.has("[SERIAL]"));
CHECK(out.find("TX ping") != std::string::npos);
REQUIRE_FALSE(cap.cats.empty());
CHECK(cap.cats.front() == LogCat::Serial);
}
}
TEST_CASE("the console can be silenced without silencing the file") {
// `--log-level off` now means the CONSOLE is off. A tower quietened on
// screen must still be writing its record.
LoggerStateGuard guard;
Captured cap;
Logger::setFileSink([&](LogLevel, LogCat, const std::string& l) { cap.lines.push_back(l); });
Logger::setLevel(LogLevel::Off);
Logger::setFileLevel(LogLevel::Info);
const std::string out = captureCout([] { LOG_ERROR << "still-recorded"; });
CHECK(cap.has("still-recorded"));
CHECK(out.find("still-recorded") == std::string::npos);
}
TEST_CASE("with no file sink the logger behaves exactly as before") {
LoggerStateGuard guard;
Logger::setFileSink({});
Logger::setFileLevel(LogLevel::Off);
Logger::setLevel(LogLevel::Info);
// Off is the identity for the min(), so the gate is bit-identical to the
// pre-change behaviour.
CHECK(Logger::effectiveLevel() == LogLevel::Info);
CHECK_FALSE(Logger::enabled(LogLevel::Debug));
CHECK(Logger::enabled(LogLevel::Info));
const std::string out = captureCout([] { LOG_INFO << "unchanged"; });
CHECK(out.find("unchanged") != std::string::npos);
}
TEST_CASE("setFileLevelFromString accepts the same words as the console one") {
LoggerStateGuard guard;
CHECK(Logger::setFileLevelFromString("debug"));
CHECK(Logger::fileLevel() == LogLevel::Debug);
CHECK(Logger::setFileLevelFromString("WARNING"));
CHECK(Logger::fileLevel() == LogLevel::Warn);
CHECK(Logger::setFileLevelFromString("off"));
CHECK(Logger::fileLevel() == LogLevel::Off);
// A bad word leaves it untouched rather than guessing.
CHECK_FALSE(Logger::setFileLevelFromString("loud"));
CHECK(Logger::fileLevel() == LogLevel::Off);
}