#include "test.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { struct stderr_capture { std::ostringstream stream{}; std::streambuf *previous{std::cerr.rdbuf(stream.rdbuf())}; ~stderr_capture(); }; auto parse_positive(std::int32_t value) -> std::expected { if (value <= std::int32_t{0}) { return modulus::fail(modulus::make_error("test", std::int32_t{7}, "value must be positive")); } return modulus::success(value); } stderr_capture::~stderr_capture() { std::cerr.rdbuf(previous); } } BOOST_AUTO_TEST_CASE(modulus_result_carries_success_and_failure_error) { auto ok = parse_positive(std::int32_t{42}); BOOST_REQUIRE(ok.has_value()); BOOST_TEST(*ok == std::int32_t{42}); auto failed = parse_positive(std::int32_t{0}); BOOST_REQUIRE(!failed.has_value()); BOOST_TEST(std::string{failed.error().category()} == "test"); BOOST_TEST(failed.error().code() == std::int32_t{7}); BOOST_TEST(failed.error().describe().find("value must be positive") != std::string::npos); BOOST_TEST(std::string{failed.error().location().file_name()}.find("test.cpp") != std::string::npos); } BOOST_AUTO_TEST_CASE(modulus_asserts_follow_build_type) { #if defined(MODULUS_EXPECT_NDEBUG) && !defined(NDEBUG) BOOST_FAIL("release-style builds must define NDEBUG"); #endif #if defined(MODULUS_EXPECT_DEBUG_ASSERTS) && defined(NDEBUG) BOOST_FAIL("debug builds must not define NDEBUG"); #endif BOOST_TEST(true); } BOOST_AUTO_TEST_CASE(modulus_types_and_source_locations_are_available) { auto value32 = std::float32_t{1}; auto value64 = std::float64_t{2}; BOOST_TEST(static_cast(value32) < value64); auto location = modulus::source_location::current(); BOOST_TEST(location.file_name() != nullptr); BOOST_TEST(std::string{location.file_name()}.find("test.cpp") != std::string::npos); } BOOST_AUTO_TEST_CASE(modulus_logger_filters_and_reports_caller_location) { auto records = std::vector{}; auto logger = modulus::logger{}; logger.set_level(modulus::log_level::DEBUG); logger.set_sink([&](const modulus::log_record &record) { records.push_back(record); }); logger.log(modulus::log_level::TRACE, "hidden"); logger.log(modulus::log_level::DEBUG, "visible"); BOOST_REQUIRE(records.size() == std::size_t{1}); BOOST_TEST(records.front().message == "visible"); BOOST_TEST(std::string{records.front().location.file_name()}.find("test.cpp") != std::string::npos); } BOOST_AUTO_TEST_CASE(modulus_logger_serializes_custom_sink_writes) { auto records = std::vector{}; auto logger = modulus::logger{}; logger.set_level(modulus::log_level::INFO); logger.set_sink([&](const modulus::log_record &record) { records.push_back(record); }); auto threads = std::vector{}; for (auto thread = std::int32_t{}; thread < std::int32_t{4}; ++thread) { threads.emplace_back([thread, &logger]() -> void { for (auto message = std::int32_t{}; message < std::int32_t{50}; ++message) { logger.log(modulus::log_level::INFO, "custom-log-" + std::to_string(thread) + "-" + std::to_string(message)); } }); } for (auto &thread : threads) { thread.join(); } BOOST_TEST(records.size() == std::size_t{200}); auto messages = std::set{}; for (const auto &record : records) { messages.insert(record.message); } BOOST_TEST(messages.size() == std::size_t{200}); } BOOST_AUTO_TEST_CASE(modulus_logger_tolerates_concurrent_configuration_changes) { auto records = std::vector{}; auto logger = modulus::logger{}; logger.set_level(modulus::log_level::INFO); logger.set_sink([&](const modulus::log_record &record) { records.push_back(record); }); auto sink = [&](const modulus::log_record &record) -> void { records.push_back(record); }; auto mutator = std::thread{[&]() -> void { for (auto iteration = std::int32_t{}; iteration < std::int32_t{50}; ++iteration) { logger.set_level(modulus::log_level::DEBUG); logger.set_sink(sink); logger.set_level(modulus::log_level::INFO); } }}; auto threads = std::vector{}; for (auto thread = std::int32_t{}; thread < std::int32_t{4}; ++thread) { threads.emplace_back([thread, &logger]() -> void { for (auto message = std::int32_t{}; message < std::int32_t{50}; ++message) { logger.log(modulus::log_level::INFO, "mutation-log-" + std::to_string(thread) + "-" + std::to_string(message)); } }); } for (auto &thread : threads) { thread.join(); } mutator.join(); BOOST_TEST(records.size() == std::size_t{200}); } BOOST_AUTO_TEST_CASE(modulus_lazy_logging_skips_disabled_messages) { modulus::set_default_log_level(modulus::log_level::ERROR); auto side_effects = std::int32_t{}; modulus::log_lazy(modulus::log_level::DEBUG, [&]() -> std::string { return std::string{"hidden "} + std::to_string(++side_effects); }); BOOST_TEST(side_effects == std::int32_t{0}); } BOOST_AUTO_TEST_CASE(modulus_default_logger_serializes_stderr_lines) { auto capture = stderr_capture{}; modulus::set_default_log_level(modulus::log_level::INFO); auto threads = std::vector{}; for (auto thread = std::int32_t{}; thread < std::int32_t{4}; ++thread) { threads.emplace_back([thread]() -> void { for (auto message = std::int32_t{}; message < std::int32_t{20}; ++message) { modulus::log_info("thread-log-" + std::to_string(thread) + "-" + std::to_string(message)); } }); } for (auto &thread : threads) { thread.join(); } auto captured = std::istringstream{capture.stream.str()}; auto messages = std::set{}; auto line = std::string{}; while (std::getline(captured, line)) { BOOST_TEST(line.starts_with("[info] thread-log-")); auto end = line.find(" ("); BOOST_REQUIRE(end != std::string::npos); messages.insert(line.substr(7, end - 7)); } BOOST_TEST(messages.size() == std::size_t{80}); } BOOST_AUTO_TEST_CASE(modulus_free_logging_reports_caller_location) { auto capture = stderr_capture{}; modulus::set_default_log_level(modulus::log_level::INFO); modulus::log_info("free log source"); modulus::log_lazy(modulus::log_level::INFO, []() -> std::string { return "lazy log source"; }); auto output = capture.stream.str(); BOOST_TEST(output.find("free log source") != std::string::npos); BOOST_TEST(output.find("lazy log source") != std::string::npos); BOOST_TEST(output.find("test.cpp") != std::string::npos); } BOOST_AUTO_TEST_CASE(modulus_error_describe_honors_formatting_config) { auto error = modulus::make_error("test", std::int32_t{7}, "value must be positive"); auto message_only = modulus::error_config{ .include_category = false, .include_code = false, .include_message = true, .include_location = false, }; BOOST_TEST(error.describe(message_only) == "value must be positive"); modulus::set_default_error_config(message_only); BOOST_TEST(error.describe() == "value must be positive"); modulus::set_default_error_config(modulus::error_config{}); } BOOST_AUTO_TEST_CASE(modulus_config_parses_defaults_and_logging_level) { auto defaults = modulus::parse_config(boost::json::parse("{}")); BOOST_REQUIRE(defaults.has_value()); BOOST_TEST(static_cast(defaults->logging.level) == static_cast(modulus::log_level::INFO)); auto parsed = modulus::parse_config(boost::json::parse(R"json({"logging":{"level":"debug"}})json")); BOOST_REQUIRE(parsed.has_value()); BOOST_TEST(static_cast(parsed->logging.level) == static_cast(modulus::log_level::DEBUG)); auto round_trip = modulus::parse_config(modulus::to_json(*parsed)); BOOST_REQUIRE(round_trip.has_value()); BOOST_TEST(static_cast(round_trip->logging.level) == static_cast(modulus::log_level::DEBUG)); } BOOST_AUTO_TEST_CASE(modulus_config_rejects_invalid_logging_values) { auto bad_level = modulus::parse_config(boost::json::parse(R"json({"logging":{"level":"loud"}})json")); BOOST_REQUIRE(!bad_level.has_value()); BOOST_TEST(std::string{bad_level.error().category()} == "modulus.config"); auto bad_shape = modulus::parse_config(boost::json::parse(R"json({"logging":[]})json")); BOOST_REQUIRE(!bad_shape.has_value()); BOOST_TEST(bad_shape.error().describe().find("logging config must be an object") != std::string::npos); auto unknown_root = modulus::parse_config(boost::json::parse(R"json({"loging":{"level":"debug"}})json")); BOOST_REQUIRE(!unknown_root.has_value()); BOOST_TEST(unknown_root.error().describe().find("config root has unknown key: loging") != std::string::npos); auto unknown_logging = modulus::parse_config(boost::json::parse(R"json({"logging":{"leve":"debug"}})json")); BOOST_REQUIRE(!unknown_logging.has_value()); BOOST_TEST(unknown_logging.error().describe().find("logging config has unknown key: leve") != std::string::npos); auto unknown_errors = modulus::parse_config(boost::json::parse(R"json({"errors":{"include_messages":false}})json")); BOOST_REQUIRE(!unknown_errors.has_value()); BOOST_TEST(unknown_errors.error().describe().find("error config has unknown key: include_messages") != std::string::npos); } BOOST_AUTO_TEST_CASE(modulus_config_parses_error_formatting) { auto parsed = modulus::parse_config(boost::json::parse(R"json({"errors":{"include_category":false,"include_code":false,"include_location":false}})json")); BOOST_REQUIRE(parsed.has_value()); BOOST_TEST(!parsed->errors.include_category); BOOST_TEST(!parsed->errors.include_code); BOOST_TEST(parsed->errors.include_message); BOOST_TEST(!parsed->errors.include_location); } BOOST_AUTO_TEST_CASE(modulus_configure_applies_default_logger_and_error_config) { auto parsed = modulus::parse_config(boost::json::parse(R"json({"logging":{"level":"off"},"errors":{"include_category":false,"include_code":false,"include_location":false}})json")); BOOST_REQUIRE(parsed.has_value()); modulus::configure(*parsed); BOOST_TEST(!modulus::is_log_enabled(modulus::log_level::CRITICAL)); auto error = modulus::make_error("test", std::int32_t{1}, "configured"); BOOST_TEST(error.describe() == "configured"); modulus::set_default_log_level(modulus::log_level::INFO); modulus::set_default_error_config(modulus::error_config{}); } BOOST_AUTO_TEST_CASE(modulus_load_config_handles_files) { auto path = std::filesystem::temp_directory_path() / "modulus-config-test.json"; auto output = std::ofstream{path}; output << R"json({"logging":{"level":"warn"}})json"; output.close(); auto loaded = modulus::load_config(path.string()); BOOST_REQUIRE(loaded.has_value()); BOOST_TEST(static_cast(loaded->logging.level) == static_cast(modulus::log_level::WARN)); auto missing = modulus::load_config((std::filesystem::temp_directory_path() / "modulus-config-missing.json").string()); BOOST_REQUIRE(!missing.has_value()); BOOST_TEST(missing.error().describe().find("config file not found") != std::string::npos); auto invalid_path = std::filesystem::temp_directory_path() / "modulus-config-invalid.json"; auto invalid_output = std::ofstream{invalid_path}; invalid_output << "{"; invalid_output.close(); auto invalid = modulus::load_config(invalid_path.string()); BOOST_REQUIRE(!invalid.has_value()); } BOOST_AUTO_TEST_CASE(modulus_json_float_fields_validate_numeric_types_and_bounds) { auto object = boost::json::object{}; auto options = modulus::json_config::options{.category = "domain", .invalid_argument_code = 42}; auto read = [&] { return modulus::json_config::optional_float64_field(object, "gain", 0.5, 0.0, 1.0, options); }; BOOST_REQUIRE(read()); BOOST_TEST(static_cast(*read()) == 0.5); for (auto const &value : {boost::json::value{0}, boost::json::value{std::uint64_t{1}}, boost::json::value{0.25}}) { object["gain"] = value; BOOST_REQUIRE(read()); } for (auto const &value : {boost::json::value{-1}, boost::json::value{2U}, boost::json::value{true}, boost::json::value{"0.5"}, boost::json::value{nullptr}, boost::json::value{std::numeric_limits::infinity()}, boost::json::value{std::numeric_limits::quiet_NaN()}}) { object["gain"] = value; auto result = read(); BOOST_REQUIRE(!result); BOOST_TEST(std::string{result.error().category()} == "domain"); BOOST_TEST(result.error().code() == 42); } }