#include #include "fgc/Logger.h" #include #include #include #include using namespace fgc; namespace { // Run a callable with std::cout redirected to a buffer; return what was written. template 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(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 lines; std::vector 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(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); }