diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000..6a912ee8 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,30 @@ +# xo-testutil/CMakeLists.txt + +cmake_minimum_required(VERSION 3.10) + +project(xo_testutil VERSION 1.0) +enable_language(CXX) + +include(GNUInstallDirs) +include(cmake/xo-bootstrap-macros.cmake) + +xo_cxx_toplevel_options3() + +# ---------------------------------------------------------------- +# c++ settings + +# one-time project-specific c++ flags. usually empty +set(PROJECT_CXX_FLAGS "") +add_definitions(${PROJECT_CXX_FLAGS}) + +# ---------------------------------------------------------------- +# output targets + +add_subdirectory(src/testutil) + +# ---------------------------------------------------------------- +# cmake export + +xo_export_cmake_config(${PROJECT_NAME} ${PROJECT_VERSION} ${PROJECT_NAME}Targets) + +# end CMakeLists.txt diff --git a/cmake/xo-bootstrap-macros.cmake b/cmake/xo-bootstrap-macros.cmake new file mode 100644 index 00000000..2cf387e5 --- /dev/null +++ b/cmake/xo-bootstrap-macros.cmake @@ -0,0 +1,33 @@ +# ---------------------------------------------------------------- +# for example: +# $ PREFIX=/usr/local # for example +# $ cmake -DCMAKE_MODULE_PATH=prefix -DCMAKE_INSTALL_PREFIX=$PREFIX -B .build +# +# will get +# CMAKE_MODULE_PATH +# from xo-cmake-config --cmake-module-path +# +# and expect .cmake macros in +# CMAKE_MODULE_PATH/xo_macros/xo_cxx.cmake +# ---------------------------------------------------------------- + +find_program(XO_CMAKE_CONFIG_EXECUTABLE NAMES xo-cmake-config REQUIRED) + +if (("${CMAKE_MODULE_PATH}" STREQUAL "") OR ("${CMAKE_MODULE_PATH}" STREQUAL "prefix")) + message(FATAL "could not find xo-cmake-config executable") +endif() + +if (NOT XO_SUBMODULE_BUILD) + if (("${CMAKE_MODULE_PATH}" STREQUAL "") OR ("${CMAKE_MODULE_PATH}" STREQUAL prefix)) + # default to typical install location for xo-project-macros + execute_process(COMMAND ${XO_CMAKE_CONFIG_EXECUTABLE} --cmake-module-path OUTPUT_VARIABLE CMAKE_MODULE_PATH) + message(STATUS "CMAKE_MODULE_PATH=${CMAKE_MODULE_PATH}") + endif() +endif() + +# needs to have been installed somewhere on CMAKE_MODULE_PATH, +# (e.g. from xo-cmake with the same value for CMAKE_INSTALL_PREFIX) +# +include(xo_macros/xo_cxx) + +xo_cxx_bootstrap_message() diff --git a/cmake/xo_testutilConfig.cmake.in b/cmake/xo_testutilConfig.cmake.in new file mode 100644 index 00000000..e7ffa898 --- /dev/null +++ b/cmake/xo_testutilConfig.cmake.in @@ -0,0 +1,14 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +# note: changes to find_dependency() calls here +# must coordinate with xo_dependency() calls +# in CMakeLists.txt +# +find_dependency(subsys) +find_dependency(indentlog) + +include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Share.cmake") +check_required_components("@PROJECT_NAME@") diff --git a/include/xo/testutil/Utest.hpp b/include/xo/testutil/Utest.hpp new file mode 100644 index 00000000..e8516b36 --- /dev/null +++ b/include/xo/testutil/Utest.hpp @@ -0,0 +1,35 @@ +/** @file Utest.hpp + * + * @author Roland Conybeare, May 2026 + **/ + +#pragma once + +#include + +namespace xo { + + /** RAII logging for catch2 unit tests + * + * Use: + * TEST_CASE(name, tags, ..) + * { + * scope log = Utest::ut_scope(); + * + * ... + * log && log(xtag("foo", ...)); + * } + * + * Honors: + * UtestConfig::instance()->debug_flag_ + **/ + struct Utest { + /** Toplevel logging scope for unit tests. + * Integrates with UtestConfig + **/ + static scope ut_scope(); + }; + +} /*namespace xo*/ + +/* end Utest.hpp */ diff --git a/include/xo/testutil/UtestAppStart.hpp b/include/xo/testutil/UtestAppStart.hpp new file mode 100644 index 00000000..3cbe683d --- /dev/null +++ b/include/xo/testutil/UtestAppStart.hpp @@ -0,0 +1,30 @@ +/** @file UtestAppStart.hpp + * + * @author Roland Conybeare, May 2026 + **/ + +#pragma once + +namespace xo { + + /** @brief Startup sequence for a unit test + * + * Standard unit test startup sequence + **/ + class UtestAppStart { + public: + explicit UtestAppStart(const char * app_name) : app_name_{app_name} {} + + /** + * Parse program arguments; recognize XO test arguments, + * sending remainder to catch2; do subsystem initialization + **/ + int run(int argc, char * argv[]); + + private: + const char * app_name_ = ""; + }; + +} /*namespace xo*/ + +/* end UtestAppStart.hpp */ diff --git a/include/xo/testutil/UtestConfig.hpp b/include/xo/testutil/UtestConfig.hpp new file mode 100644 index 00000000..9c8179ec --- /dev/null +++ b/include/xo/testutil/UtestConfig.hpp @@ -0,0 +1,25 @@ +/** @file UtestConfig.hpp + * + * @author Roland Conybeare, May 2026 + **/ + +namespace xo { + + /** unit-test configuration here + * + * TODO: promote to its own library, along with UtestListener + **/ + struct UtestConfig { + bool debug_flag() const { return debug_flag_; } + + /** announce each test using catch2's listener api **/ + bool announce_flag_ = false; + /** enable debug output for all (!) tests **/ + bool debug_flag_ = false; + + static UtestConfig * instance(); + }; + +} + +/* end UtestConfig.hpp */ diff --git a/include/xo/testutil/UtestListener.hpp b/include/xo/testutil/UtestListener.hpp new file mode 100644 index 00000000..a76d50fb --- /dev/null +++ b/include/xo/testutil/UtestListener.hpp @@ -0,0 +1,52 @@ +/** @file UtestListener.hpp + * + * @author Roland Conybeare, May 2026 + **/ + +#pragma once + +#include "UtestConfig.hpp" + +// caller must define CATCH_CONFIG_RUNNER +#include + +namespace xo { + + /** @brief listener for catch2 unit tests. + * catch2 invokes this at the beginning of each unit test + * + * Enable with: + * @begin_code + * #include + * CATCH_REGISTER_LISTENER(UtestListener); + * @end_code + **/ + struct UtestListener : Catch::TestEventListenerBase { + using TestEventListenerBase::TestEventListenerBase; + + // TestCasweInfo members: .name, .className, .description, .tags, lineInfo {.file, .line} + virtual void testCaseStarting(const Catch::TestCaseInfo & info) override { + using std::cerr; + using std::endl; + + // preamble + + if (UtestConfig::instance()->announce_flag_) { + cerr << "Starting unit test: " + << "[" << info.name << "]" + << " at " + << "[" << info.lineInfo.file << ":" << info.lineInfo.line << "]" + << endl; + } + } + + virtual void testCaseEnded(const Catch::TestCaseStats & stats) override { + // postamble + } + + // also sectionStarting / sectionEnded + + }; +} + +/* end UtestListener.hpp */ diff --git a/src/testutil/CMakeLists.txt b/src/testutil/CMakeLists.txt new file mode 100644 index 00000000..b43b6144 --- /dev/null +++ b/src/testutil/CMakeLists.txt @@ -0,0 +1,15 @@ +# testutil/CMakeLists.txt + +set(SELF_LIB xo_testutil) +set(SELF_SRCS + UtestAppStart.cpp + UtestConfig.cpp + Utest.cpp +) + +xo_add_shared_library4(${SELF_LIB} ${PROJECT_NAME}Targets ${PROJECT_VERSION} 1 ${SELF_SRCS}) +xo_install_include_tree3(include/xo/testutil) +# note: deps here must also appear in cmake/xo_testutilConfig.cmake.in +xo_dependency(${SELF_LIB} subsys) +xo_dependency(${SELF_LIB} indentlog) +xo_external_target_dependency(${SELF_LIB} Catch2 Catch2::Catch2) diff --git a/src/testutil/Utest.cpp b/src/testutil/Utest.cpp new file mode 100644 index 00000000..c0632819 --- /dev/null +++ b/src/testutil/Utest.cpp @@ -0,0 +1,18 @@ +/** @file Utest.cpp + * + * @author Roland Conybeare, May 2026 + **/ + +#include "Utest.hpp" +#include "UtestConfig.hpp" +#include + +namespace xo { + scope + Utest::ut_scope() { + return scope(XO_DEBUG(UtestConfig::instance()->debug_flag()), + xtag("name", Catch::getResultCapture().getCurrentTestName())); + } +} + +/* end Utest.cpp */ diff --git a/src/testutil/UtestAppStart.cpp b/src/testutil/UtestAppStart.cpp new file mode 100644 index 00000000..433c76a1 --- /dev/null +++ b/src/testutil/UtestAppStart.cpp @@ -0,0 +1,75 @@ +/** @file UtestAppStart.cpp + * + * @author Roland Conybeare, May 2026 + **/ + +#include "UtestAppStart.hpp" +#include "UtestConfig.hpp" +#include +#include +#include + +#define CATCH_CONFIG_RUNNER +#include + +namespace xo { + using xo::UtestConfig; + using xo::scope; + using xo::xtag; + + using std::cout; + using std::cerr; + using std::endl; + + int + UtestAppStart::run(int argc, char * argv[]) + { + CLI::App app{app_name_}; + + app.set_help_flag(); // disable default help impl, see below + { + app.add_flag("--debug", + UtestConfig::instance()->debug_flag_, + "enable debug logging (for all tests)"); + + app.add_flag("--announce", + UtestConfig::instance()->announce_flag_, + "announce each test via UtestListener"); + } + + bool help_flag = false; + { + app.add_flag("--help,-h,-?", help_flag, "print this help message and exit"); + } + + app.allow_extras(); + CLI11_PARSE(app, argc, argv); + + std::vector argv2 = {argv[0]}; + + if (help_flag) { + // actual help impl, falls through to Session below + + cout << app_name_ << " options" << endl; + cout << app.help() << endl; + cout << "catch2 options" << endl; + + argv2.push_back("--help"); + } else { + // keep program name + for (auto & x : app.remaining()) + argv2.push_back(x.c_str()); + } + + using xo::Subsystem; + Subsystem::initialize_all(); + + scope log(XO_DEBUG(UtestConfig::instance()->debug_flag()), + "start catch2 session"); + + // run catch2's test session / help + return Catch::Session().run(argv2.size(), argv2.data()); + } +} /*namespace xo*/ + +/* end UtestAppStart.cpp */ diff --git a/src/testutil/UtestConfig.cpp b/src/testutil/UtestConfig.cpp new file mode 100644 index 00000000..49bd2c4c --- /dev/null +++ b/src/testutil/UtestConfig.cpp @@ -0,0 +1,18 @@ +/** @file UtestConfig.cpp + * + * @author Roland Conybeare, May 2026 + **/ + +#include "UtestConfig.hpp" +#include + +namespace xo { + UtestConfig * + UtestConfig::instance() { + static UtestConfig s_instance; + + return &s_instance; + } +} + +/* end UtestConfig.cpp */