From b98d8da306c7a6db0d7ddc266f1d9ead40fb66d8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:29:42 -0400 Subject: [PATCH 01/10] Add CMake C++ env helper library Introduce a new CMake-based `lizardbyte::common` static library with cross-platform environment variable helpers (`get_env`, `set_env`, `append_env`, `unset_env`), packaging/export config, and install rules. Add C++ unit tests and wire them into CTest, then add a new multi-OS CMake job in CI to configure, build, test, and install the project. Update README and `.gitignore` to document and support the new C++ workflow. --- .github/workflows/ci.yml | 31 +++++ .gitignore | 1 + CMakeLists.txt | 84 +++++++++++++ README.md | 34 ++++- cmake/lizardbyte-common-config.cmake.in | 3 + src/CMakeLists.txt | 40 ++++++ src/common/env.cpp | 88 +++++++++++++ src/include/lizardbyte/common/env.h | 42 +++++++ tests/CMakeLists.txt | 7 ++ tests/cpp/test_env.cpp | 161 ++++++++++++++++++++++++ 10 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 CMakeLists.txt create mode 100644 cmake/lizardbyte-common-config.cmake.in create mode 100644 src/CMakeLists.txt create mode 100644 src/common/env.cpp create mode 100644 src/include/lizardbyte/common/env.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/cpp/test_env.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 498405d..dcbee72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,12 +109,43 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} verbose: true + cmake: + name: CMake (${{ matrix.os }}) + needs: + - release-setup + permissions: + contents: read + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - ubuntu-latest + - windows-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Configure + run: cmake -DBUILD_TESTS=ON -DCMAKE_BUILD_TYPE:STRING=Debug -B cmake-build-ci -S . + + - name: Build + run: cmake --build cmake-build-ci --config Debug --parallel 2 + + - name: Test + run: ctest --test-dir cmake-build-ci --build-config Debug --output-on-failure + + - name: Install + run: cmake --install cmake-build-ci --config Debug --prefix cmake-build-ci/install + create-release: name: Create Release if: >- (github.event_name == 'push' && github.ref == 'refs/heads/master') && needs.release-setup.outputs.publish_release == 'true' needs: + - cmake - release-setup - pytest permissions: {} diff --git a/.gitignore b/.gitignore index cc32f96..23817f4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ build/ Build/ build-*/ +cmake-build-*/ # CMake generated files CMakeFiles/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6cb007a --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,84 @@ +# +# Project configuration +# +cmake_minimum_required(VERSION 3.24) +project( + lizardbyte_common + VERSION 0.0.0 + DESCRIPTION "Common helpers and tooling for LizardByte projects." + HOMEPAGE_URL "https://app.lizardbyte.dev" + LANGUAGES CXX) + +set(PROJECT_LICENSE "MIT") +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(LIZARDBYTE_COMMON_IS_TOP_LEVEL OFF) +if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) + set(LIZARDBYTE_COMMON_IS_TOP_LEVEL ON) +endif() + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + message(STATUS "Setting build type to 'Release' as none was specified.") + set(CMAKE_BUILD_TYPE + "Release" + CACHE STRING "Choose the type of build." FORCE) +endif() + +# +# Project optional configuration +# +option(BUILD_TESTS "Build tests" ${LIZARDBYTE_COMMON_IS_TOP_LEVEL}) +option(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS + "Treat lizardbyte-common warnings as errors" + ${LIZARDBYTE_COMMON_IS_TOP_LEVEL}) + +set(CMAKE_COLOR_MAKEFILE ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +include(CMakePackageConfigHelpers) +include(GNUInstallDirs) + +# +# Additional setup for coverage +# https://gcovr.com/en/stable/guide/compiling.html#compiler-options +# +if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME + AND BUILD_TESTS + AND NOT CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + set(CMAKE_CXX_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") + set(CMAKE_C_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") +endif() + +# +# Library code is located here When building tests this must be after the +# coverage flags are set +# +add_subdirectory(src) + +# +# Tests are top-level only +# +if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +# +# Package config +# +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/lizardbyte-common-config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/lizardbyte-common-config.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/lizardbyte-common") + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/lizardbyte-common-config-version.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +install( + FILES "${CMAKE_CURRENT_BINARY_DIR}/lizardbyte-common-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/lizardbyte-common-config-version.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/lizardbyte-common") diff --git a/README.md b/README.md index f47de14..1558df2 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,15 @@ ## Overview -This repository contains shared helper scripts and repository-level tooling used across LizardByte projects. +This repository contains shared helper scripts, repository-level tooling, and C++ helpers used across LizardByte +projects. -The current tooling includes Python-managed helpers and reusable GitHub workflows: +The current tooling includes Python-managed helpers, reusable GitHub workflows, and a small C++ helper library: - `scripts/update_clang_format.py` runs `clang-format` across supported source directories. - `scripts/localize.py` updates gettext and Babel locale files. - `.github/workflows/localize.yml` runs the locale helper from GitHub Actions and opens localization update pull requests. +- `lizardbyte::common` provides shared C++ helpers, starting with environment variable manipulation. ## Python Tooling @@ -44,6 +46,27 @@ Run gettext extraction: uv run --locked --only-group locale lb-localize --extract ``` +## C++ Tooling + +Configure, build, and test the C++ helpers with CMake: + +```bash +cmake -DBUILD_TESTS=ON -B build -S . +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` + +The C++ library exports the `lizardbyte::common` target and public headers under `lizardbyte/common/`. + +```cpp +#include + +std::string value; +if (lizardbyte::common::get_env("MY_ENV", value)) { + lizardbyte::common::append_env("MY_ENV", "suffix", ";"); +} +``` + ## Consuming Projects Projects with a `pyproject.toml` can use this repository as a local path dependency. For a submodule at @@ -82,6 +105,13 @@ lb-update-clang-format lb-localize --extract ``` +CMake projects can consume the C++ helpers from the same submodule: + +```cmake +add_subdirectory(third-party/lizardbyte-common) +target_link_libraries(my_target PRIVATE lizardbyte::common) +``` + ## Workflows Reusable GitHub workflows live under `.github/workflows/`. diff --git a/cmake/lizardbyte-common-config.cmake.in b/cmake/lizardbyte-common-config.cmake.in new file mode 100644 index 0000000..cfbc34d --- /dev/null +++ b/cmake/lizardbyte-common-config.cmake.in @@ -0,0 +1,3 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/lizardbyte-common-targets.cmake") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..61402a8 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,40 @@ +add_library(lizardbyte_common STATIC) +add_library(lizardbyte::common ALIAS lizardbyte_common) + +target_sources(lizardbyte_common + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/common/env.cpp") + +target_include_directories( + lizardbyte_common PUBLIC $ + $) + +target_compile_features(lizardbyte_common PUBLIC cxx_std_17) +set_target_properties( + lizardbyte_common PROPERTIES EXPORT_NAME common OUTPUT_NAME lizardbyte-common) + +if(MSVC) + target_compile_options(lizardbyte_common PRIVATE /W4) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common PRIVATE /WX) + endif() +else() + target_compile_options(lizardbyte_common PRIVATE -Wall -Wextra -Wpedantic) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common PRIVATE -Werror) + endif() +endif() + +install( + TARGETS lizardbyte_common + EXPORT lizardbyte-common-targets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + +install(DIRECTORY "${PROJECT_SOURCE_DIR}/src/include/" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +install( + EXPORT lizardbyte-common-targets + NAMESPACE lizardbyte:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/lizardbyte-common") diff --git a/src/common/env.cpp b/src/common/env.cpp new file mode 100644 index 0000000..9d26476 --- /dev/null +++ b/src/common/env.cpp @@ -0,0 +1,88 @@ +/** + * @file src/common/env.cpp + * @brief Environment variable helper implementation. + */ + +// standard includes +#include +#include + +// local includes +#include + +namespace lizardbyte::common { + namespace { + [[nodiscard]] bool is_valid_name(const std::string &name) { + return !name.empty() && name.find('=') == std::string::npos; + } + } // namespace + + bool get_env(const std::string &name, std::string &value) { + if (!is_valid_name(name)) { + return false; + } + +#if defined(_WIN32) && defined(_MSC_VER) + char *buffer {}; + std::size_t buffer_size {}; + if (_dupenv_s(&buffer, &buffer_size, name.c_str()) != 0 || buffer == nullptr) { + return false; + } + + value = buffer; + std::free(buffer); + return true; +#else + const auto *env_value = std::getenv(name.c_str()); + if (env_value == nullptr) { + return false; + } + + value = env_value; + return true; +#endif + } + + int set_env(const std::string &name, const std::string &value) { + if (!is_valid_name(name)) { + return EINVAL; + } + +#if defined(_WIN32) + return _putenv_s(name.c_str(), value.c_str()); +#else + return setenv(name.c_str(), value.c_str(), 1); +#endif + } + + int append_env(const std::string &name, const std::string &value, const std::string &separator) { + if (!is_valid_name(name)) { + return EINVAL; + } + + std::string old_value; + static_cast(get_env(name, old_value)); + + if (old_value.find(value) != std::string::npos) { + return 0; + } + + if (old_value.empty()) { + return set_env(name, value); + } + + return set_env(name, old_value + separator + value); + } + + int unset_env(const std::string &name) { + if (!is_valid_name(name)) { + return EINVAL; + } + +#if defined(_WIN32) + return _putenv_s(name.c_str(), ""); +#else + return unsetenv(name.c_str()); +#endif + } +} // namespace lizardbyte::common diff --git a/src/include/lizardbyte/common/env.h b/src/include/lizardbyte/common/env.h new file mode 100644 index 0000000..d85510f --- /dev/null +++ b/src/include/lizardbyte/common/env.h @@ -0,0 +1,42 @@ +/** + * @file src/include/lizardbyte/common/env.h + * @brief Environment variable helper declarations. + */ +#pragma once + +// standard includes +#include + +namespace lizardbyte::common { + /** + * @brief Get an environment variable. + * @param name The name of the environment variable. + * @param value Reference to write the environment variable value into. + * @return true if value was updated, false if the environment variable did not exist. + */ + [[nodiscard]] bool get_env(const std::string &name, std::string &value); + + /** + * @brief Set an environment variable. + * @param name The name of the environment variable. + * @param value The value to set the environment variable to. + * @return 0 on success, non-zero on failure. + */ + [[nodiscard]] int set_env(const std::string &name, const std::string &value); + + /** + * @brief Append a string to an environment variable if it does not already contain it. + * @param name The name of the environment variable. + * @param value The value to append to the environment variable. + * @param separator Optional separator for the new value if it is not the first one. + * @return 0 on success, non-zero on failure. + */ + [[nodiscard]] int append_env(const std::string &name, const std::string &value, const std::string &separator = ""); + + /** + * @brief Unset an environment variable. + * @param name The name of the environment variable. + * @return 0 on success, non-zero on failure. + */ + [[nodiscard]] int unset_env(const std::string &name); +} // namespace lizardbyte::common diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..4d4f698 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,7 @@ +set(TEST_BINARY test_lizardbyte_common) + +add_executable(${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_env.cpp") + +target_link_libraries(${TEST_BINARY} PRIVATE lizardbyte::common) + +add_test(NAME ${TEST_BINARY} COMMAND ${TEST_BINARY}) diff --git a/tests/cpp/test_env.cpp b/tests/cpp/test_env.cpp new file mode 100644 index 0000000..078d6fd --- /dev/null +++ b/tests/cpp/test_env.cpp @@ -0,0 +1,161 @@ +/** + * @file tests/cpp/test_env.cpp + * @brief Unit tests for environment variable helpers. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include + +namespace { + constexpr auto test_env_name {"LIZARDBYTE_COMMON_TEST_ENV"}; + + int failures {}; + + class EnvGuard { + public: + explicit EnvGuard(std::string name): + name_ {std::move(name)}, + had_value_ {lizardbyte::common::get_env(name_, old_value_)} { + static_cast(lizardbyte::common::unset_env(name_)); + } + + ~EnvGuard() { + if (had_value_) { + static_cast(lizardbyte::common::set_env(name_, old_value_)); + } else { + static_cast(lizardbyte::common::unset_env(name_)); + } + } + + private: + std::string name_; + std::string old_value_; + bool had_value_; + }; + + void expect_true(const bool condition, const char *expression, const char *file, const int line) { + if (condition) { + return; + } + + std::cerr << file << ':' << line << ": expected true: " << expression << '\n'; + ++failures; + } + + void expect_false(const bool condition, const char *expression, const char *file, const int line) { + if (!condition) { + return; + } + + std::cerr << file << ':' << line << ": expected false: " << expression << '\n'; + ++failures; + } + + template + void expect_eq(const L &left, const R &right, const char *left_expression, const char *right_expression, const char *file, const int line) { + if (left == right) { + return; + } + + std::cerr << file << ':' << line << ": expected equality: " << left_expression << " == " << right_expression << " (actual: " << left << " != " << right << ")\n"; + ++failures; + } + +#define EXPECT_TRUE(expression) expect_true((expression), #expression, __FILE__, __LINE__) +#define EXPECT_FALSE(expression) expect_false((expression), #expression, __FILE__, __LINE__) +#define EXPECT_EQ(left, right) expect_eq((left), (right), #left, #right, __FILE__, __LINE__) + + void get_env_returns_false_for_missing_variable() { + EnvGuard guard {test_env_name}; + + std::string value {"unchanged"}; + + EXPECT_FALSE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "unchanged"); + } + + void set_env_updates_environment_variable() { + EnvGuard guard {test_env_name}; + + std::string value; + + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha"); + } + + void append_env_sets_missing_environment_variable() { + EnvGuard guard {test_env_name}; + + std::string value; + + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "alpha", ";"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha"); + } + + void append_env_appends_with_separator() { + EnvGuard guard {test_env_name}; + + std::string value; + + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "beta", ";"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha;beta"); + } + + void append_env_skips_existing_value() { + EnvGuard guard {test_env_name}; + + std::string value; + + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha;beta"), 0); + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "beta", ";"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha;beta"); + } + + void unset_env_removes_environment_variable() { + EnvGuard guard {test_env_name}; + + std::string value; + + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(lizardbyte::common::unset_env(test_env_name), 0); + EXPECT_FALSE(lizardbyte::common::get_env(test_env_name, value)); + } + + void invalid_env_names_are_rejected() { + std::string value {"unchanged"}; + + EXPECT_FALSE(lizardbyte::common::get_env("", value)); + EXPECT_FALSE(lizardbyte::common::get_env("INVALID=NAME", value)); + EXPECT_EQ(lizardbyte::common::set_env("", "value"), EINVAL); + EXPECT_EQ(lizardbyte::common::set_env("INVALID=NAME", "value"), EINVAL); + EXPECT_EQ(lizardbyte::common::append_env("", ""), EINVAL); + EXPECT_EQ(lizardbyte::common::append_env("INVALID=NAME", ""), EINVAL); + EXPECT_EQ(lizardbyte::common::unset_env(""), EINVAL); + EXPECT_EQ(lizardbyte::common::unset_env("INVALID=NAME"), EINVAL); + EXPECT_EQ(value, "unchanged"); + } +} // namespace + +int main() { + get_env_returns_false_for_missing_variable(); + set_env_updates_environment_variable(); + append_env_sets_missing_environment_variable(); + append_env_appends_with_separator(); + append_env_skips_existing_value(); + unset_env_removes_environment_variable(); + invalid_env_names_are_rejected(); + + return failures == 0 ? 0 : 1; +} From 244b6e01fbc47579c5e270e1679c6c49482a4b8f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:36:26 -0400 Subject: [PATCH 02/10] Add googletest as a git submodule Adds `third-party/googletest` as a new submodule and records its source URL in `.gitmodules`. The submodule is pinned to commit `52eb8108c5bdec04579160ae17225d66034bd723` for reproducible dependency state. --- .gitmodules | 3 +++ third-party/googletest | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 third-party/googletest diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3f127b8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third-party/googletest"] + path = third-party/googletest + url = https://github.com/google/googletest.git diff --git a/third-party/googletest b/third-party/googletest new file mode 160000 index 0000000..52eb810 --- /dev/null +++ b/third-party/googletest @@ -0,0 +1 @@ +Subproject commit 52eb8108c5bdec04579160ae17225d66034bd723 From ddc9d87accc1a20ffef3a667ddafbe27dff90e91 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:40:04 -0400 Subject: [PATCH 03/10] Add doxyconfig as third-party submodule Registers `third-party/doxyconfig` in `.gitmodules` with the LizardByte `doxyconfig` repository on the `master` branch, and adds the submodule pointer at commit `e552f7c`. --- .gitmodules | 4 ++++ third-party/doxyconfig | 1 + 2 files changed, 5 insertions(+) create mode 160000 third-party/doxyconfig diff --git a/.gitmodules b/.gitmodules index 3f127b8..4e26a7d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ +[submodule "third-party/doxyconfig"] + path = third-party/doxyconfig + url = https://github.com/LizardByte/doxyconfig.git + branch = master [submodule "third-party/googletest"] path = third-party/googletest url = https://github.com/google/googletest.git diff --git a/third-party/doxyconfig b/third-party/doxyconfig new file mode 160000 index 0000000..e552f7c --- /dev/null +++ b/third-party/doxyconfig @@ -0,0 +1 @@ +Subproject commit e552f7c9f00cd0cf24764a37d0905935b3d2c188 From a2872af97a3db4bbe163fdfa654b6b5a8272b278 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:10:18 -0400 Subject: [PATCH 04/10] Refactor tests to GoogleTest and expand CI/CD Major improvements to the project's testing and CI/CD infrastructure: - Convert C++ tests from custom runner to GoogleTest framework - Add lizardbyte::test_support library with shared test fixtures (BaseTest, LinuxTest, MacOSTest, WindowsTest) - Expand CI/CD pipeline to test multiple compilers (GCC, Clang) and platforms (Linux, macOS, Windows with MinGW and MSVC) - Add OpenCppCoverage support for MSVC code coverage - Add separate codecov job for aggregating and uploading coverage reports - Add ReadTheDocs configuration and Doxygen documentation support - Add get_env() string overload to env API - Update documentation with GoogleTest support helpers and build instructions --- .github/workflows/ci.yml | 313 +++++++++++++++++- .readthedocs.yaml | 30 ++ CMakeLists.txt | 37 ++- README.md | 43 ++- docs/Doxyfile | 40 +++ src/common/env.cpp | 6 + src/include/lizardbyte/common/env.h | 7 + tests/CMakeLists.txt | 11 +- tests/cpp/test_env.cpp | 174 ++++------ tests/cpp/test_testing.cpp | 138 ++++++++ tests/support/CMakeLists.txt | 29 ++ .../include/lizardbyte/common/testing.h | 211 ++++++++++++ tests/support/testing.cpp | 151 +++++++++ 13 files changed, 1071 insertions(+), 119 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 docs/Doxyfile create mode 100644 tests/cpp/test_testing.cpp create mode 100644 tests/support/CMakeLists.txt create mode 100644 tests/support/include/lizardbyte/common/testing.h create mode 100644 tests/support/testing.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcbee72..9cdcbec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ concurrency: cancel-in-progress: true env: + OPENCPPCOVERAGE_VERSION: '0.9.9.0' PYTHON_VERSION: '3.14' jobs: @@ -109,43 +110,335 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} verbose: true - cmake: - name: CMake (${{ matrix.os }}) + build: + name: Build (${{ matrix.name }}) needs: - release-setup permissions: contents: read runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ matrix.shell }} strategy: fail-fast: false matrix: - os: - - macos-latest - - ubuntu-latest - - windows-latest + include: + - name: Linux-GCC + os: ubuntu-latest + shell: bash + kind: unix + cc: gcc + cxx: g++ + gcov_executable: gcov + - name: Linux-Clang + os: ubuntu-latest + shell: bash + kind: unix + cc: clang + cxx: clang++ + # Clang writes LLVM coverage notes, so gcovr needs llvm-cov's gcov compatibility mode. + gcov_executable: llvm-cov gcov + - name: macOS + os: macos-latest + shell: bash + kind: unix + cc: clang + cxx: clang++ + gcov_executable: gcov + - name: Windows-MinGW-UCRT64 + os: windows-latest + shell: msys2 {0} + kind: msys2 + cc: gcc + cxx: g++ + msystem: ucrt64 + toolchain: ucrt-x86_64 + gcov_executable: gcov + - name: Windows-MSVC + os: windows-2022 + shell: pwsh + kind: msvc steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + + - name: Setup Dependencies Linux + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + clang \ + cmake \ + llvm \ + ninja-build + + - name: Setup Dependencies macOS + if: runner.os == 'macOS' + run: | + brew install \ + cmake \ + ninja + + - name: Setup Dependencies Windows MinGW + if: matrix.kind == 'msys2' + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 + with: + msystem: ${{ matrix.msystem }} + update: true + install: >- + mingw-w64-${{ matrix.toolchain }}-cmake + mingw-w64-${{ matrix.toolchain }}-ninja + mingw-w64-${{ matrix.toolchain }}-toolchain + + - name: Setup Dependencies Windows MSVC + if: matrix.kind == 'msvc' + run: | + choco install opencppcoverage --version=${{ env.OPENCPPCOVERAGE_VERSION }} --yes --no-progress + + $openCppCoverageDir = "${env:ProgramFiles}\OpenCppCoverage" + if (!(Test-Path (Join-Path $openCppCoverageDir "OpenCppCoverage.exe"))) { + $openCppCoverageDir = "${env:ProgramFiles(x86)}\OpenCppCoverage" + } + if (!(Test-Path (Join-Path $openCppCoverageDir "OpenCppCoverage.exe"))) { + throw "OpenCppCoverage.exe was not found after Chocolatey install." + } + + $openCppCoverageDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Set up Python + id: setup-python + if: matrix.kind != 'msvc' + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Set up uv + if: matrix.kind != 'msvc' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Sync Python tools + if: matrix.kind != 'msvc' + env: + MSYS2_PATH_TYPE: inherit + UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} + run: | + uv sync --locked --only-group test-c \ + --no-python-downloads \ + --no-install-project - name: Configure - run: cmake -DBUILD_TESTS=ON -DCMAKE_BUILD_TYPE:STRING=Debug -B cmake-build-ci -S . + if: matrix.kind != 'msvc' + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + run: | + cmake \ + -DBUILD_DOCS=OFF \ + -DBUILD_TESTS=ON \ + -DCMAKE_BUILD_TYPE:STRING=Debug \ + -B cmake-build-ci \ + -G Ninja \ + -S . + + - name: Configure MSVC + if: matrix.kind == 'msvc' + run: | + cmake ` + -DBUILD_DOCS=OFF ` + -DBUILD_TESTS=ON ` + -A x64 ` + -B cmake-build-ci ` + -G "Visual Studio 17 2022" ` + -S . - name: Build + if: matrix.kind != 'msvc' + run: cmake --build cmake-build-ci -- -j2 + + - name: Build MSVC + if: matrix.kind == 'msvc' run: cmake --build cmake-build-ci --config Debug --parallel 2 - - name: Test - run: ctest --test-dir cmake-build-ci --build-config Debug --output-on-failure + - name: Prepare report directory + run: cmake -E make_directory cmake-build-ci/reports + + - name: Run tests + id: test + if: matrix.kind != 'msvc' + working-directory: cmake-build-ci/tests + run: ./test_lizardbyte_common --gtest_color=yes --gtest_output=xml:../reports/junit.xml + + - name: Run tests MSVC + id: test_msvc + if: matrix.kind == 'msvc' + run: | + $openCppCoverage = (Get-Command OpenCppCoverage.exe -ErrorAction SilentlyContinue).Source + if (!$openCppCoverage) { + $candidates = @( + "${env:ProgramFiles}\OpenCppCoverage\OpenCppCoverage.exe", + "${env:ProgramFiles(x86)}\OpenCppCoverage\OpenCppCoverage.exe" + ) + $openCppCoverage = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + } + if (!$openCppCoverage) { + throw "OpenCppCoverage.exe was not found." + } + + & $openCppCoverage ` + --sources "$env:GITHUB_WORKSPACE\src" ` + --sources "$env:GITHUB_WORKSPACE\tests\support" ` + "--export_type=cobertura:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\coverage.xml" ` + --working_dir "$env:GITHUB_WORKSPACE\cmake-build-ci\tests" ` + -- ` + "$env:GITHUB_WORKSPACE\cmake-build-ci\tests\Debug\test_lizardbyte_common.exe" ` + --gtest_color=yes ` + "--gtest_output=xml:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\junit.xml" + + - name: Normalize MSVC coverage paths + if: >- + always() && + matrix.kind == 'msvc' && + (steps.test_msvc.outcome == 'success' || steps.test_msvc.outcome == 'failure') + run: | + $coveragePath = Join-Path $env:GITHUB_WORKSPACE "cmake-build-ci\reports\coverage.xml" + if (!(Test-Path $coveragePath)) { + return + } + + [xml] $coverage = Get-Content $coveragePath + $workspace = $env:GITHUB_WORKSPACE.Replace('\', '/') + foreach ($node in $coverage.SelectNodes('//*[@filename]')) { + $filename = $node.GetAttribute('filename').Replace('\', '/') + if ($filename.StartsWith("${workspace}/")) { + $filename = $filename.Substring($workspace.Length + 1) + } + + $node.SetAttribute('filename', $filename) + } + + foreach ($source in $coverage.SelectNodes('//source')) { + $source.InnerText = '.' + } + + $coverage.Save($coveragePath) + + - name: Generate gcov report + id: test_report + if: >- + always() && + matrix.kind != 'msvc' && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + working-directory: cmake-build-ci + env: + GCOV_EXECUTABLE: ${{ matrix.gcov_executable }} + MSYS2_PATH_TYPE: inherit + run: | + uv run --project .. --locked --no-sync gcovr . -r .. \ + --filter ../src \ + --filter ../tests/support \ + --gcov-executable "${GCOV_EXECUTABLE}" \ + --exclude-noncode-lines \ + --exclude-throw-branches \ + --exclude-unreachable-branches \ + --verbose \ + --xml-pretty \ + -o reports/coverage.xml - name: Install + if: matrix.kind != 'msvc' + run: cmake --install cmake-build-ci --prefix cmake-build-ci/install + + - name: Install MSVC + if: matrix.kind == 'msvc' run: cmake --install cmake-build-ci --config Debug --prefix cmake-build-ci/install + - name: Upload report artifact + if: >- + always() && + ( + steps.test_report.outcome == 'success' || + steps.test_msvc.outcome == 'success' || + steps.test_msvc.outcome == 'failure' + ) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reports-${{ matrix.name }} + path: cmake-build-ci/reports + if-no-files-found: error + + codecov: + name: Codecov (${{ matrix.flag }}) + if: >- + always() && + (needs.build.result == 'success' || needs.build.result == 'failure') && + startsWith(github.repository, 'LizardByte/') + needs: + - build + permissions: + contents: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - build_name: Linux-GCC + flag: Linux-GCC + - build_name: Linux-Clang + flag: Linux-Clang + - build_name: macOS + flag: macOS + - build_name: Windows-MinGW-UCRT64 + flag: Windows-MinGW-UCRT64 + - build_name: Windows-MSVC + flag: Windows-MSVC + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Download report artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reports-${{ matrix.build_name }} + path: _reports + + - name: Debug coverage file + run: cat _reports/coverage.xml + + - name: Upload test coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./_reports/coverage.xml + flags: ${{ matrix.flag }} + report_type: coverage + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + - name: Upload test results + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./_reports/junit.xml + flags: ${{ matrix.flag }} + report_type: test_results + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + create-release: name: Create Release if: >- (github.event_name == 'push' && github.ref == 'refs/heads/master') && needs.release-setup.outputs.publish_release == 'true' needs: - - cmake + - build - release-setup - pytest permissions: {} diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..ee2f3bb --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,30 @@ +--- +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "miniconda-latest" + commands: + - | + if [ -f readthedocs_build.sh ]; then + doxyconfig_dir="." + else + doxyconfig_dir="./third-party/doxyconfig" + fi + chmod +x "${doxyconfig_dir}/readthedocs_build.sh" + export DOXYCONFIG_DIR="${doxyconfig_dir}" + "${doxyconfig_dir}/readthedocs_build.sh" + +# using conda, we can get newer doxygen and graphviz than ubuntu provide +# https://github.com/readthedocs/readthedocs.org/issues/8151#issuecomment-890359661 +conda: + environment: third-party/doxyconfig/environment.yml + +submodules: + include: all + recursive: true diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cb007a..9e8694a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,9 @@ endif() # Project optional configuration # option(BUILD_TESTS "Build tests" ${LIZARDBYTE_COMMON_IS_TOP_LEVEL}) +option(BUILD_DOCS "Build documentation" ${LIZARDBYTE_COMMON_IS_TOP_LEVEL}) +option(LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT "Build GoogleTest support helpers" + ${BUILD_TESTS}) option(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS "Treat lizardbyte-common warnings as errors" ${LIZARDBYTE_COMMON_IS_TOP_LEVEL}) @@ -58,11 +61,37 @@ endif() add_subdirectory(src) # -# Tests are top-level only +# Test support may be used by consuming projects # -if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTS) - enable_testing() - add_subdirectory(tests) +if(LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT) + set(INSTALL_GTEST OFF) + set(INSTALL_GMOCK OFF) + if(WIN32) + # cmake-lint: disable=C0103 + set(gtest_force_shared_crt + ON + CACHE BOOL "Always use msvcrt.dll" FORCE) + endif() + + if(NOT TARGET gtest) + add_subdirectory(third-party/googletest third-party/googletest) + endif() + + add_subdirectory(tests/support) +endif() + +# +# Tests and docs are top-level only +# +if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) + if(BUILD_DOCS) + add_subdirectory(third-party/doxyconfig docs) + endif() + + if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() endif() # diff --git a/README.md b/README.md index 1558df2..1815345 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The current tooling includes Python-managed helpers, reusable GitHub workflows, - `scripts/localize.py` updates gettext and Babel locale files. - `.github/workflows/localize.yml` runs the locale helper from GitHub Actions and opens localization update pull requests. - `lizardbyte::common` provides shared C++ helpers, starting with environment variable manipulation. +- `lizardbyte::test_support` provides shared GoogleTest fixtures and test macros for LizardByte C++ projects. ## Python Tooling @@ -48,10 +49,16 @@ uv run --locked --only-group locale lb-localize --extract ## C++ Tooling +Initialize submodules before configuring the C++ targets: + +```bash +git submodule update --init --recursive +``` + Configure, build, and test the C++ helpers with CMake: ```bash -cmake -DBUILD_TESTS=ON -B build -S . +cmake -DBUILD_DOCS=OFF -DBUILD_TESTS=ON -B build -S . cmake --build build --parallel ctest --test-dir build --output-on-failure ``` @@ -67,6 +74,23 @@ if (lizardbyte::common::get_env("MY_ENV", value)) { } ``` +The optional test support target is available when `BUILD_TESTS=ON` or `LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT=ON`. + +```cpp +#include + +TEST(MySuite, CapturesOutputUntilFailure) { + std::cout << "only printed when this test fails"; +} +``` + +Build the Doxygen documentation through the shared doxyconfig submodule: + +```bash +cmake -DBUILD_DOCS=ON -DBUILD_TESTS=OFF -B build/docs -S . +cmake --build build/docs --target docs +``` + ## Consuming Projects Projects with a `pyproject.toml` can use this repository as a local path dependency. For a submodule at @@ -112,6 +136,15 @@ add_subdirectory(third-party/lizardbyte-common) target_link_libraries(my_target PRIVATE lizardbyte::common) ``` +To consume the shared GoogleTest support helpers from a project that already builds tests, link the test binary to +`lizardbyte::test_support`: + +```cmake +set(LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT ON) +add_subdirectory(third-party/lizardbyte-common) +target_link_libraries(my_test_binary PRIVATE lizardbyte::test_support) +``` + ## Workflows Reusable GitHub workflows live under `.github/workflows/`. @@ -149,3 +182,11 @@ Run the pytest suite: ```bash uv run --locked --only-group test-python pytest ``` + +Run the C++ GoogleTest suite: + +```bash +cmake -DBUILD_DOCS=OFF -DBUILD_TESTS=ON -B build -S . +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` diff --git a/docs/Doxyfile b/docs/Doxyfile new file mode 100644 index 0000000..65c8f58 --- /dev/null +++ b/docs/Doxyfile @@ -0,0 +1,40 @@ +# This file describes the settings to be used by the documentation system +# doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). +# +# Note: +# +# Use doxygen to compare the used configuration file with the template +# configuration file: +# doxygen -x [configFile] +# Use doxygen to compare the used configuration file with the template +# configuration file without replacing the environment variables or CMake type +# replacement variables: +# doxygen -x_noenv [configFile] + +# project metadata +DOCSET_BUNDLE_ID = dev.lizardbyte.lizardbyte-common +DOCSET_PUBLISHER_ID = dev.lizardbyte.lizardbyte-common.documentation +PROJECT_BRIEF = "Common helper scripts, repository tooling, and C++ helpers for LizardByte projects." +PROJECT_NAME = lizardbyte-common + +# project specific settings +DOT_GRAPH_MAX_NODES = 50 +INCLUDE_PATH = +WARN_IF_UNDOCUMENTED = YES + +# files and directories to process +USE_MDFILE_AS_MAINPAGE = ../README.md +INPUT = ../README.md \ + ../third-party/doxyconfig/docs/source_code.md \ + ../src/include \ + ../tests/support/include diff --git a/src/common/env.cpp b/src/common/env.cpp index 9d26476..a6fe184 100644 --- a/src/common/env.cpp +++ b/src/common/env.cpp @@ -43,6 +43,12 @@ namespace lizardbyte::common { #endif } + std::string get_env(const std::string &name) { + std::string value; + static_cast(get_env(name, value)); + return value; + } + int set_env(const std::string &name, const std::string &value) { if (!is_valid_name(name)) { return EINVAL; diff --git a/src/include/lizardbyte/common/env.h b/src/include/lizardbyte/common/env.h index d85510f..51c7fd6 100644 --- a/src/include/lizardbyte/common/env.h +++ b/src/include/lizardbyte/common/env.h @@ -16,6 +16,13 @@ namespace lizardbyte::common { */ [[nodiscard]] bool get_env(const std::string &name, std::string &value); + /** + * @brief Get an environment variable. + * @param name The name of the environment variable. + * @return Environment variable value, or an empty string if the variable does not exist. + */ + [[nodiscard]] std::string get_env(const std::string &name); + /** * @brief Set an environment variable. * @param name The name of the environment variable. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4d4f698..8826faa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,7 +1,12 @@ +include(GoogleTest) + set(TEST_BINARY test_lizardbyte_common) -add_executable(${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_env.cpp") +add_executable( + ${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_env.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/cpp/test_testing.cpp") -target_link_libraries(${TEST_BINARY} PRIVATE lizardbyte::common) +target_link_libraries(${TEST_BINARY} PRIVATE gmock_main lizardbyte::common + lizardbyte::test_support) -add_test(NAME ${TEST_BINARY} COMMAND ${TEST_BINARY}) +gtest_discover_tests(${TEST_BINARY}) diff --git a/tests/cpp/test_env.cpp b/tests/cpp/test_env.cpp index 078d6fd..bd73faa 100644 --- a/tests/cpp/test_env.cpp +++ b/tests/cpp/test_env.cpp @@ -5,18 +5,17 @@ // standard includes #include -#include +#include #include #include // local includes #include +#include namespace { constexpr auto test_env_name {"LIZARDBYTE_COMMON_TEST_ENV"}; - int failures {}; - class EnvGuard { public: explicit EnvGuard(std::string name): @@ -38,124 +37,97 @@ namespace { std::string old_value_; bool had_value_; }; +} // namespace - void expect_true(const bool condition, const char *expression, const char *file, const int line) { - if (condition) { - return; - } - - std::cerr << file << ':' << line << ": expected true: " << expression << '\n'; - ++failures; - } - - void expect_false(const bool condition, const char *expression, const char *file, const int line) { - if (!condition) { - return; - } - - std::cerr << file << ':' << line << ": expected false: " << expression << '\n'; - ++failures; - } - - template - void expect_eq(const L &left, const R &right, const char *left_expression, const char *right_expression, const char *file, const int line) { - if (left == right) { - return; - } - - std::cerr << file << ':' << line << ": expected equality: " << left_expression << " == " << right_expression << " (actual: " << left << " != " << right << ")\n"; - ++failures; - } - -#define EXPECT_TRUE(expression) expect_true((expression), #expression, __FILE__, __LINE__) -#define EXPECT_FALSE(expression) expect_false((expression), #expression, __FILE__, __LINE__) -#define EXPECT_EQ(left, right) expect_eq((left), (right), #left, #right, __FILE__, __LINE__) - - void get_env_returns_false_for_missing_variable() { - EnvGuard guard {test_env_name}; +TEST(EnvTest, GetEnvReturnsFalseForMissingVariable) { + EnvGuard guard {test_env_name}; - std::string value {"unchanged"}; + std::string value {"unchanged"}; - EXPECT_FALSE(lizardbyte::common::get_env(test_env_name, value)); - EXPECT_EQ(value, "unchanged"); - } + EXPECT_FALSE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "unchanged"); + EXPECT_EQ(lizardbyte::common::get_env(test_env_name), ""); +} - void set_env_updates_environment_variable() { - EnvGuard guard {test_env_name}; +TEST(EnvTest, SetEnvUpdatesEnvironmentVariable) { + EnvGuard guard {test_env_name}; - std::string value; + std::string value; - EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); - EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); - EXPECT_EQ(value, "alpha"); - } + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha"); + EXPECT_EQ(lizardbyte::common::get_env(test_env_name), "alpha"); + EXPECT_STREQ(std::getenv(test_env_name), "alpha"); +} - void append_env_sets_missing_environment_variable() { - EnvGuard guard {test_env_name}; +TEST(EnvTest, AppendEnvSetsMissingEnvironmentVariable) { + EnvGuard guard {test_env_name}; - std::string value; + std::string value; - EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "alpha", ";"), 0); - EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); - EXPECT_EQ(value, "alpha"); - } + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "alpha", ";"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha"); +} - void append_env_appends_with_separator() { - EnvGuard guard {test_env_name}; +TEST(EnvTest, AppendEnvAppendsWithSeparator) { + EnvGuard guard {test_env_name}; - std::string value; + std::string value; - EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); - EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "beta", ";"), 0); - EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); - EXPECT_EQ(value, "alpha;beta"); - } + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "beta", ";"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha;beta"); +} - void append_env_skips_existing_value() { - EnvGuard guard {test_env_name}; +TEST(EnvTest, AppendEnvSkipsExistingValue) { + EnvGuard guard {test_env_name}; - std::string value; + std::string value; - EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha;beta"), 0); - EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "beta", ";"), 0); - EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); - EXPECT_EQ(value, "alpha;beta"); - } + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha;beta"), 0); + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "beta", ";"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "alpha;beta"); +} - void unset_env_removes_environment_variable() { - EnvGuard guard {test_env_name}; +TEST(EnvTest, AppendEnvMatchesSunshineCommaSeparatedUse) { + EnvGuard guard {test_env_name}; - std::string value; + std::string value; - EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); - EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); - EXPECT_EQ(lizardbyte::common::unset_env(test_env_name), 0); - EXPECT_FALSE(lizardbyte::common::get_env(test_env_name, value)); - } + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "video_encode", ","), 0); + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "rt", ","), 0); + EXPECT_EQ(lizardbyte::common::append_env(test_env_name, "video_encode", ","), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(value, "video_encode,rt"); +} - void invalid_env_names_are_rejected() { - std::string value {"unchanged"}; +TEST(EnvTest, UnsetEnvRemovesEnvironmentVariable) { + EnvGuard guard {test_env_name}; - EXPECT_FALSE(lizardbyte::common::get_env("", value)); - EXPECT_FALSE(lizardbyte::common::get_env("INVALID=NAME", value)); - EXPECT_EQ(lizardbyte::common::set_env("", "value"), EINVAL); - EXPECT_EQ(lizardbyte::common::set_env("INVALID=NAME", "value"), EINVAL); - EXPECT_EQ(lizardbyte::common::append_env("", ""), EINVAL); - EXPECT_EQ(lizardbyte::common::append_env("INVALID=NAME", ""), EINVAL); - EXPECT_EQ(lizardbyte::common::unset_env(""), EINVAL); - EXPECT_EQ(lizardbyte::common::unset_env("INVALID=NAME"), EINVAL); - EXPECT_EQ(value, "unchanged"); - } -} // namespace + std::string value; -int main() { - get_env_returns_false_for_missing_variable(); - set_env_updates_environment_variable(); - append_env_sets_missing_environment_variable(); - append_env_appends_with_separator(); - append_env_skips_existing_value(); - unset_env_removes_environment_variable(); - invalid_env_names_are_rejected(); + EXPECT_EQ(lizardbyte::common::set_env(test_env_name, "alpha"), 0); + EXPECT_TRUE(lizardbyte::common::get_env(test_env_name, value)); + EXPECT_EQ(lizardbyte::common::unset_env(test_env_name), 0); + EXPECT_FALSE(lizardbyte::common::get_env(test_env_name, value)); +} - return failures == 0 ? 0 : 1; +TEST(EnvTest, InvalidEnvNamesAreRejected) { + std::string value {"unchanged"}; + + EXPECT_FALSE(lizardbyte::common::get_env("", value)); + EXPECT_FALSE(lizardbyte::common::get_env("INVALID=NAME", value)); + EXPECT_EQ(lizardbyte::common::get_env(""), ""); + EXPECT_EQ(lizardbyte::common::get_env("INVALID=NAME"), ""); + EXPECT_EQ(lizardbyte::common::set_env("", "value"), EINVAL); + EXPECT_EQ(lizardbyte::common::set_env("INVALID=NAME", "value"), EINVAL); + EXPECT_EQ(lizardbyte::common::append_env("", ""), EINVAL); + EXPECT_EQ(lizardbyte::common::append_env("INVALID=NAME", ""), EINVAL); + EXPECT_EQ(lizardbyte::common::unset_env(""), EINVAL); + EXPECT_EQ(lizardbyte::common::unset_env("INVALID=NAME"), EINVAL); + EXPECT_EQ(value, "unchanged"); } diff --git a/tests/cpp/test_testing.cpp b/tests/cpp/test_testing.cpp new file mode 100644 index 0000000..8e457f1 --- /dev/null +++ b/tests/cpp/test_testing.cpp @@ -0,0 +1,138 @@ +/** + * @file tests/cpp/test_testing.cpp + * @brief Unit tests for shared GoogleTest support helpers. + */ + +// standard includes +#include +#include +#include + +// local includes +#include +#include + +namespace { + class TestableBaseTest: public BaseTest { + public: + using BaseTest::cerrBuffer; + using BaseTest::coutBuffer; + using BaseTest::getArgWithMatchingPattern; + using BaseTest::isOutputSuppressed; + using BaseTest::isSystemTest; + using BaseTest::skipTest; + }; + + class EnvGuard { + public: + explicit EnvGuard(std::string name): + name_ {std::move(name)}, + had_value_ {lizardbyte::common::get_env(name_, old_value_)} { + static_cast(lizardbyte::common::unset_env(name_)); + } + + ~EnvGuard() { + if (had_value_) { + static_cast(lizardbyte::common::set_env(name_, old_value_)); + } else { + static_cast(lizardbyte::common::unset_env(name_)); + } + } + + private: + std::string name_; + std::string old_value_; + bool had_value_; + }; + + class SystemTestFixture: public BaseTest { + protected: + void SetUp() override { + skip_system_tests_guard_.emplace("SKIP_SYSTEM_TESTS"); + BaseTest::SetUp(); + } + + [[nodiscard]] bool isSystemTest() const override { + return true; + } + + private: + std::optional skip_system_tests_guard_; + }; + + class OutputVisibleFixture: public BaseTest { + protected: + [[nodiscard]] bool isOutputSuppressed() const override { + return false; + } + }; +} // namespace + +#define TESTING_SUPPORT_TEST(...) LB_MAKE_TEST(TEST, TestingSupportMacroTest, __VA_ARGS__) + +TEST(TestingSupportTest, DefaultTestMacroUsesBaseFixture) { + EXPECT_TRUE(isOutputSuppressed()); + std::cout << "captured cout"; + std::cerr << "captured cerr"; + EXPECT_EQ(coutBuffer().str(), "captured cout"); + EXPECT_EQ(cerrBuffer().str(), "captured cerr"); +} + +TEST(TestingSupportTest, ArgumentMatchingCanReturnFullArgument) { + EXPECT_TRUE(getArgWithMatchingPattern("--gtest_", false).has_value()); +} + +TEST(TestingSupportTest, ArgumentMatchingCanRemoveMatchedPrefix) { + const auto argument = getArgWithMatchingPattern("--gtest_", true); + + ASSERT_TRUE(argument.has_value()); + EXPECT_FALSE(argument->empty()); +} + +TEST(TestingSupportTest, DefaultBaseTestIsNotSystemTest) { + EXPECT_FALSE(isSystemTest()); + EXPECT_EQ(skipTest(), ""); +} + +TEST_F(OutputVisibleFixture, CanDisableOutputSuppression) { + EXPECT_FALSE(isOutputSuppressed()); +} + +TEST_F(SystemTestFixture, RespectsSkipSystemTestsEnvironment) { + EXPECT_EQ(skipTest(), ""); + EXPECT_EQ(lizardbyte::common::set_env("SKIP_SYSTEM_TESTS", "1"), 0); + EXPECT_NE(skipTest(), ""); +} + +TEST_F(TestableBaseTest, HelpersAreAvailableOnDerivedFixtures) { + EXPECT_TRUE(isOutputSuppressed()); + EXPECT_FALSE(isSystemTest()); +} + +TESTING_SUPPORT_TEST(Joins, Multiple, Name, Parts) { + SUCCEED(); +} + +TEST_F(LinuxTest, SkipsOrRunsLinuxFixture) { +#if defined(__linux__) + SUCCEED(); +#else + GTEST_FAIL() << "LinuxTest should skip before the test body on non-Linux platforms."; +#endif +} + +TEST_F(MacOSTest, SkipsOrRunsMacOSFixture) { +#if defined(__APPLE__) && defined(__MACH__) + SUCCEED(); +#else + GTEST_FAIL() << "MacOSTest should skip before the test body on non-macOS platforms."; +#endif +} + +TEST_F(WindowsTest, SkipsOrRunsWindowsFixture) { +#if defined(_WIN32) + SUCCEED(); +#else + GTEST_FAIL() << "WindowsTest should skip before the test body on non-Windows platforms."; +#endif +} diff --git a/tests/support/CMakeLists.txt b/tests/support/CMakeLists.txt new file mode 100644 index 0000000..4ef7e74 --- /dev/null +++ b/tests/support/CMakeLists.txt @@ -0,0 +1,29 @@ +add_library(lizardbyte_common_test_support STATIC) +add_library(lizardbyte::test_support ALIAS lizardbyte_common_test_support) + +target_sources(lizardbyte_common_test_support + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/testing.cpp") + +target_include_directories( + lizardbyte_common_test_support + PUBLIC $) + +target_link_libraries(lizardbyte_common_test_support PUBLIC gtest + lizardbyte::common) + +target_compile_features(lizardbyte_common_test_support PUBLIC cxx_std_17) +set_target_properties(lizardbyte_common_test_support + PROPERTIES OUTPUT_NAME lizardbyte-common-test-support) + +if(MSVC) + target_compile_options(lizardbyte_common_test_support PRIVATE /W4) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common_test_support PRIVATE /WX) + endif() +else() + target_compile_options(lizardbyte_common_test_support PRIVATE -Wall -Wextra + -Wpedantic) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common_test_support PRIVATE -Werror) + endif() +endif() diff --git a/tests/support/include/lizardbyte/common/testing.h b/tests/support/include/lizardbyte/common/testing.h new file mode 100644 index 0000000..e24a140 --- /dev/null +++ b/tests/support/include/lizardbyte/common/testing.h @@ -0,0 +1,211 @@ +/** + * @file tests/support/include/lizardbyte/common/testing.h + * @brief Shared GoogleTest support helpers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +// lib includes +#include + +namespace lizardbyte::common::testing { + /** + * @brief Base class used by default for shared tests. + * + * ``cout`` and ``cerr`` are redirected during tests and printed when a test fails. + */ + class BaseTest: public ::testing::Test { + protected: + /** + * @brief Tear down the test base. + */ + ~BaseTest() override = default; + + /** + * @brief Set up the test. + */ + void SetUp() override; + + /** + * @brief Tear down the test. + */ + void TearDown() override; + + /** + * @brief Get available command line arguments. + * @return Command line args from GoogleTest. + */ + [[nodiscard]] virtual const std::vector &getArgs() const; + + /** + * @brief Get the command line argument that matches the pattern. + * @param pattern Pattern to look for. + * @param remove_match Specify if the matched pattern should be removed before returning the argument. + * @return Matching command line argument, or null optional if nothing matched. + */ + [[nodiscard]] virtual std::optional getArgWithMatchingPattern( + const std::string &pattern, + bool remove_match + ) const; + + /** + * @brief Check if test output should be printed only when the test fails. + * @return True if output is suppressed, false otherwise. + */ + [[nodiscard]] virtual bool isOutputSuppressed() const; + + /** + * @brief Check if the test interacts with system settings. + * @return True if it does, false otherwise. + * + * Set ``SKIP_SYSTEM_TESTS=1`` to skip tests that return true here. + */ + [[nodiscard]] virtual bool isSystemTest() const; + + /** + * @brief Skip the test by specifying the reason. + * @return A non-empty string if the test needs to be skipped, empty string otherwise. + */ + [[nodiscard]] virtual std::string skipTest() const; + + /** + * @brief Get captured cout. + * @return Captured cout stream. + */ + [[nodiscard]] std::stringstream &coutBuffer(); + + /** + * @brief Get captured cerr. + * @return Captured cerr stream. + */ + [[nodiscard]] std::stringstream &cerrBuffer(); + + private: + std::stringstream cout_buffer_; /**< Stores cout while output is suppressed. */ + std::stringstream cerr_buffer_; /**< Stores cerr while output is suppressed. */ + std::streambuf *cout_streambuf_ {nullptr}; /**< Original cout stream buffer. */ + std::streambuf *cerr_streambuf_ {nullptr}; /**< Original cerr stream buffer. */ + bool test_skipped_at_setup_ {false}; /**< True when SetUp skipped before redirection. */ + }; + + /** + * @brief Base class for Linux-only tests. + */ + class LinuxTest: public BaseTest { + protected: + /** + * @brief Set up the test. + */ + void SetUp() override; + + /** + * @brief Check that a Linux device node is readable and writable. + * @param path Device node path. + * @return GoogleTest assertion result. + */ + static ::testing::AssertionResult HasReadableWritableDeviceNode(const char *path); + }; + + /** + * @brief Base class for macOS-only tests. + */ + class MacOSTest: public BaseTest { + protected: + /** + * @brief Set up the test. + */ + void SetUp() override; + }; + + /** + * @brief Base class for Windows-only tests. + */ + class WindowsTest: public BaseTest { + protected: + /** + * @brief Set up the test. + */ + void SetUp() override; + }; +} // namespace lizardbyte::common::testing + +#if !defined(LIZARDBYTE_COMMON_TESTING_NO_GLOBAL_ALIASES) +/** + * @brief Global compatibility alias for the shared base test fixture. + */ +using BaseTest = ::lizardbyte::common::testing::BaseTest; + +/** + * @brief Global compatibility alias for the shared Linux-only test fixture. + */ +using LinuxTest = ::lizardbyte::common::testing::LinuxTest; + +/** + * @brief Global compatibility alias for the shared macOS-only test fixture. + */ +using MacOSTest = ::lizardbyte::common::testing::MacOSTest; + +/** + * @brief Global compatibility alias for the shared Windows-only test fixture. + */ +using WindowsTest = ::lizardbyte::common::testing::WindowsTest; +#endif + +/** @cond */ +#define LB_DETAIL_CAT_IMPL(part_1, part_2) part_1##part_2 +#define LB_DETAIL_CAT(part_1, part_2) LB_DETAIL_CAT_IMPL(part_1, part_2) +#define LB_DETAIL_JOIN_1(part_1) part_1 +#define LB_DETAIL_JOIN_2(part_1, part_2) LB_DETAIL_CAT(LB_DETAIL_CAT(part_1, _), part_2) +#define LB_DETAIL_JOIN_3(part_1, part_2, part_3) LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_2(part_1, part_2), part_3) +#define LB_DETAIL_JOIN_4(part_1, part_2, part_3, part_4) \ + LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_3(part_1, part_2, part_3), part_4) +#define LB_DETAIL_JOIN_5(part_1, part_2, part_3, part_4, part_5) \ + LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_4(part_1, part_2, part_3, part_4), part_5) +#define LB_DETAIL_JOIN_6(part_1, part_2, part_3, part_4, part_5, part_6) \ + LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_5(part_1, part_2, part_3, part_4, part_5), part_6) +#define LB_DETAIL_JOIN_7(part_1, part_2, part_3, part_4, part_5, part_6, part_7) \ + LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_6(part_1, part_2, part_3, part_4, part_5, part_6), part_7) +#define LB_DETAIL_JOIN_8(part_1, part_2, part_3, part_4, part_5, part_6, part_7, part_8) \ + LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_7(part_1, part_2, part_3, part_4, part_5, part_6, part_7), part_8) + +#define LB_DETAIL_GET_9TH_ARG(_1, _2, _3, _4, _5, _6, _7, _8, _9, ...) _9 +#define LB_DETAIL_COUNT_ARGS(...) LB_DETAIL_GET_9TH_ARG(__VA_ARGS__, 8, 7, 6, 5, 4, 3, 2, 1) +#define LB_DETAIL_JOIN_DISPATCH(count, ...) LB_DETAIL_JOIN_DISPATCH_IMPL(count, __VA_ARGS__) +#define LB_DETAIL_JOIN_DISPATCH_IMPL(count, ...) LB_DETAIL_JOIN_##count(__VA_ARGS__) +/** @endcond */ + +/** + * @brief Concatenate variadic macro arguments with underscore separators. + */ +#define LB_CAT_VA_ARGS(...) LB_DETAIL_JOIN_DISPATCH(LB_DETAIL_COUNT_ARGS(__VA_ARGS__), __VA_ARGS__) + +/** + * @brief Create a test name from multiple macro arguments. + * + * Usage: + * @code + * #define TEST_S(...) LB_MAKE_TEST(TEST, MySuite, __VA_ARGS__) + * TEST_S(Does, The, Thing) {} + * @endcode + */ +#define LB_MAKE_TEST(test_macro, test_suite_name, ...) test_macro(test_suite_name, LB_CAT_VA_ARGS(__VA_ARGS__)) + +#if !defined(LIZARDBYTE_COMMON_TESTING_KEEP_GTEST_TEST) + #undef TEST // NOSONAR(cpp:S959): Tests intentionally wrap TEST to use BaseTest. + + /** + * @brief Redefine TEST to automatically use the shared BaseTest fixture. + */ + #define TEST(test_case_name, test_name) \ + GTEST_TEST_( \ + test_case_name, \ + test_name, \ + ::lizardbyte::common::testing::BaseTest, \ + ::testing::internal::GetTypeId<::lizardbyte::common::testing::BaseTest>() \ + ) +#endif diff --git a/tests/support/testing.cpp b/tests/support/testing.cpp new file mode 100644 index 0000000..9c29597 --- /dev/null +++ b/tests/support/testing.cpp @@ -0,0 +1,151 @@ +/** + * @file tests/support/testing.cpp + * @brief Shared GoogleTest support helper definitions. + */ + +// standard includes +#include +#include +#include + +// platform includes +#if defined(__linux__) + #include +#endif + +// local includes +#include +#include + +namespace lizardbyte::common::testing { + void BaseTest::SetUp() { + cout_buffer_.str({}); + cout_buffer_.clear(); + cerr_buffer_.str({}); + cerr_buffer_.clear(); + + if (const auto skip_reason {skipTest()}; !skip_reason.empty()) { + test_skipped_at_setup_ = true; + GTEST_SKIP() << skip_reason; + } + + if (isOutputSuppressed()) { + cout_streambuf_ = std::cout.rdbuf(); + cerr_streambuf_ = std::cerr.rdbuf(); + std::cout.rdbuf(cout_buffer_.rdbuf()); + std::cerr.rdbuf(cerr_buffer_.rdbuf()); + } + } + + void BaseTest::TearDown() { + if (test_skipped_at_setup_) { + return; + } + + if (isOutputSuppressed()) { + if (cout_streambuf_ != nullptr) { + std::cout.rdbuf(cout_streambuf_); + cout_streambuf_ = nullptr; + } + + if (cerr_streambuf_ != nullptr) { + std::cerr.rdbuf(cerr_streambuf_); + cerr_streambuf_ = nullptr; + } + + const auto *test_info = ::testing::UnitTest::GetInstance()->current_test_info(); + if (test_info != nullptr && test_info->result()->Failed()) { + std::cout << std::endl + << "Test failed: " << test_info->name() << std::endl + << std::endl + << "Captured cout:" << std::endl + << cout_buffer_.str() << std::endl + << "Captured cerr:" << std::endl + << cerr_buffer_.str() << std::endl; + } + } + } + + const std::vector &BaseTest::getArgs() const { + static const auto args {::testing::internal::GetArgvs()}; + return args; + } + + std::optional BaseTest::getArgWithMatchingPattern( + const std::string &pattern, + const bool remove_match + ) const { + if (const auto &args {getArgs()}; !args.empty()) { + const std::regex re_pattern {pattern}; + + for (auto it {std::next(std::begin(args))}; it != std::end(args); ++it) { + if (std::smatch match; std::regex_search(*it, match, re_pattern)) { + return remove_match ? std::regex_replace(*it, re_pattern, "") : *it; + } + } + } + + return std::nullopt; + } + + bool BaseTest::isOutputSuppressed() const { + return true; + } + + bool BaseTest::isSystemTest() const { + return false; + } + + std::string BaseTest::skipTest() const { + if (isSystemTest() && get_env("SKIP_SYSTEM_TESTS") == "1") { + return "Skipping, this system test is disabled via SKIP_SYSTEM_TESTS=1 env."; + } + + return {}; + } + + std::stringstream &BaseTest::coutBuffer() { + return cout_buffer_; + } + + std::stringstream &BaseTest::cerrBuffer() { + return cerr_buffer_; + } + + void LinuxTest::SetUp() { +#if !defined(__linux__) + GTEST_SKIP() << "Skipping, this test is for Linux only."; +#else + BaseTest::SetUp(); +#endif + } + + ::testing::AssertionResult LinuxTest::HasReadableWritableDeviceNode(const char *path) { +#if defined(__linux__) + if (::access(path, R_OK | W_OK) == 0) { + return ::testing::AssertionSuccess(); + } + + return ::testing::AssertionFailure() << path << " must be readable and writable"; +#else + static_cast(path); + return ::testing::AssertionSuccess(); +#endif + } + + void MacOSTest::SetUp() { +#if !defined(__APPLE__) || !defined(__MACH__) + GTEST_SKIP() << "Skipping, this test is for macOS only."; +#else + BaseTest::SetUp(); +#endif + } + + void WindowsTest::SetUp() { +#if !defined(_WIN32) + GTEST_SKIP() << "Skipping, this test is for Windows only."; +#else + BaseTest::SetUp(); +#endif + } +} // namespace lizardbyte::common::testing From 20c2b4927c1013f3d87e3d7b941ac5519d0b7e45 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:16:18 -0400 Subject: [PATCH 05/10] Create AGENTS.md --- AGENTS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e94d873 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +On Windows we use msys2 and ucrt64 to compile. +You need to prefix commands with `C:\msys64\msys2_shell.cmd -defterm -here -no-start -ucrt64 -c`. + +Prefix build directories with `cmake-build-`. + +The test executable is named `test_lizardbyte_common` and will be located inside the `tests` directory within +the build directory. + +The project uses gtest as a test framework. GoogleTest is vendored as a submodule under `third-party/googletest`. + +Keep the public c++ API platform-neutral. Project-specific details from consuming projects should not leak into +consumer code. + +The production c++ target is `lizardbyte::common`; keep it free of project-specific dependencies. + +The reusable gtest support target is `lizardbyte::test_support`; it may depend on GoogleTest, but should not depend +on project-specific libraries from Sunshine, tray, libvirtualhid, libdisplaydevice, or Moonlight-XboxOG. + +Public production headers live under `src/include/lizardbyte/common/`. + +Shared test-support headers live under `tests/support/include/lizardbyte/common/`. + +Documentation uses the shared `third-party/doxyconfig` submodule and Read the Docs configuration. + +Always update public documentation when changing headers or consumer-facing behavior. + +Always follow the style guidelines defined in .clang-format for c/c++ code when that file is present. From 1bb88655f44e696fbc85673742ad7b073fe832d7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:22:13 -0400 Subject: [PATCH 06/10] Add SonarQube project properties override Add a new `sonar-project.properties` file to define the repository Sonar project key and set `sonar.cfamily.reportingCppStandardOverride` to `c++17`, ensuring analysis aligns with the oldest C++ standard used across LizardByte projects. --- sonar-project.properties | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..52dce32 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,5 @@ +# Sonar project analysis properties overrides +sonar.projectKey=LizardByte_lizardbyte-common + +# support the oldest standard used in lizardbyte projects +sonar.cfamily.reportingCppStandardOverride=c++17 From 6fa51b12a9148b824e280de38843fe4d747629d3 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:26:02 -0400 Subject: [PATCH 07/10] Fix cmake lint errors --- CMakeLists.txt | 56 +++++++++++++++++------------------- src/CMakeLists.txt | 16 +++++------ tests/support/CMakeLists.txt | 18 ++++++------ 3 files changed, 44 insertions(+), 46 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e8694a..cb621a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,14 +16,14 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(LIZARDBYTE_COMMON_IS_TOP_LEVEL OFF) if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) - set(LIZARDBYTE_COMMON_IS_TOP_LEVEL ON) + set(LIZARDBYTE_COMMON_IS_TOP_LEVEL ON) endif() if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - message(STATUS "Setting build type to 'Release' as none was specified.") - set(CMAKE_BUILD_TYPE - "Release" - CACHE STRING "Choose the type of build." FORCE) + message(STATUS "Setting build type to 'Release' as none was specified.") + set(CMAKE_BUILD_TYPE + "Release" + CACHE STRING "Choose the type of build." FORCE) endif() # @@ -50,8 +50,8 @@ include(GNUInstallDirs) if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTS AND NOT CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") - set(CMAKE_CXX_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") - set(CMAKE_C_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") + set(CMAKE_CXX_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") + set(CMAKE_C_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") endif() # @@ -64,34 +64,32 @@ add_subdirectory(src) # Test support may be used by consuming projects # if(LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT) - set(INSTALL_GTEST OFF) - set(INSTALL_GMOCK OFF) - if(WIN32) - # cmake-lint: disable=C0103 - set(gtest_force_shared_crt - ON - CACHE BOOL "Always use msvcrt.dll" FORCE) - endif() - - if(NOT TARGET gtest) - add_subdirectory(third-party/googletest third-party/googletest) - endif() - - add_subdirectory(tests/support) + set(INSTALL_GTEST OFF) + set(INSTALL_GMOCK OFF) + if(WIN32) + set(gtest_force_shared_crt ON CACHE BOOL # cmake-lint: disable=C0103 + "Always use msvcrt.dll" FORCE) + endif() + + if(NOT TARGET gtest) + add_subdirectory(third-party/googletest third-party/googletest) + endif() + + add_subdirectory(tests/support) endif() # # Tests and docs are top-level only # if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) - if(BUILD_DOCS) - add_subdirectory(third-party/doxyconfig docs) - endif() - - if(BUILD_TESTS) - enable_testing() - add_subdirectory(tests) - endif() + if(BUILD_DOCS) + add_subdirectory(third-party/doxyconfig docs) + endif() + + if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() endif() # diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 61402a8..72ef932 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -13,15 +13,15 @@ set_target_properties( lizardbyte_common PROPERTIES EXPORT_NAME common OUTPUT_NAME lizardbyte-common) if(MSVC) - target_compile_options(lizardbyte_common PRIVATE /W4) - if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) - target_compile_options(lizardbyte_common PRIVATE /WX) - endif() + target_compile_options(lizardbyte_common PRIVATE /W4) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common PRIVATE /WX) + endif() else() - target_compile_options(lizardbyte_common PRIVATE -Wall -Wextra -Wpedantic) - if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) - target_compile_options(lizardbyte_common PRIVATE -Werror) - endif() + target_compile_options(lizardbyte_common PRIVATE -Wall -Wextra -Wpedantic) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common PRIVATE -Werror) + endif() endif() install( diff --git a/tests/support/CMakeLists.txt b/tests/support/CMakeLists.txt index 4ef7e74..1efd41c 100644 --- a/tests/support/CMakeLists.txt +++ b/tests/support/CMakeLists.txt @@ -16,14 +16,14 @@ set_target_properties(lizardbyte_common_test_support PROPERTIES OUTPUT_NAME lizardbyte-common-test-support) if(MSVC) - target_compile_options(lizardbyte_common_test_support PRIVATE /W4) - if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) - target_compile_options(lizardbyte_common_test_support PRIVATE /WX) - endif() + target_compile_options(lizardbyte_common_test_support PRIVATE /W4) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common_test_support PRIVATE /WX) + endif() else() - target_compile_options(lizardbyte_common_test_support PRIVATE -Wall -Wextra - -Wpedantic) - if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) - target_compile_options(lizardbyte_common_test_support PRIVATE -Werror) - endif() + target_compile_options(lizardbyte_common_test_support PRIVATE -Wall -Wextra + -Wpedantic) + if(LIZARDBYTE_COMMON_WARNINGS_AS_ERRORS) + target_compile_options(lizardbyte_common_test_support PRIVATE -Werror) + endif() endif() From 8dac6d6dce4423fb4df595bea1f8e4b963517b7c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:17:13 -0400 Subject: [PATCH 08/10] Remove variadic test-name helpers Drop the `LB_CAT_VA_ARGS` and `LB_MAKE_TEST` macro helpers from the testing support header and remove the matching test case that exercised them. --- tests/cpp/test_testing.cpp | 6 --- .../include/lizardbyte/common/testing.h | 39 ------------------- 2 files changed, 45 deletions(-) diff --git a/tests/cpp/test_testing.cpp b/tests/cpp/test_testing.cpp index 8e457f1..b65e791 100644 --- a/tests/cpp/test_testing.cpp +++ b/tests/cpp/test_testing.cpp @@ -68,8 +68,6 @@ namespace { }; } // namespace -#define TESTING_SUPPORT_TEST(...) LB_MAKE_TEST(TEST, TestingSupportMacroTest, __VA_ARGS__) - TEST(TestingSupportTest, DefaultTestMacroUsesBaseFixture) { EXPECT_TRUE(isOutputSuppressed()); std::cout << "captured cout"; @@ -109,10 +107,6 @@ TEST_F(TestableBaseTest, HelpersAreAvailableOnDerivedFixtures) { EXPECT_FALSE(isSystemTest()); } -TESTING_SUPPORT_TEST(Joins, Multiple, Name, Parts) { - SUCCEED(); -} - TEST_F(LinuxTest, SkipsOrRunsLinuxFixture) { #if defined(__linux__) SUCCEED(); diff --git a/tests/support/include/lizardbyte/common/testing.h b/tests/support/include/lizardbyte/common/testing.h index e24a140..5719101 100644 --- a/tests/support/include/lizardbyte/common/testing.h +++ b/tests/support/include/lizardbyte/common/testing.h @@ -156,45 +156,6 @@ using MacOSTest = ::lizardbyte::common::testing::MacOSTest; using WindowsTest = ::lizardbyte::common::testing::WindowsTest; #endif -/** @cond */ -#define LB_DETAIL_CAT_IMPL(part_1, part_2) part_1##part_2 -#define LB_DETAIL_CAT(part_1, part_2) LB_DETAIL_CAT_IMPL(part_1, part_2) -#define LB_DETAIL_JOIN_1(part_1) part_1 -#define LB_DETAIL_JOIN_2(part_1, part_2) LB_DETAIL_CAT(LB_DETAIL_CAT(part_1, _), part_2) -#define LB_DETAIL_JOIN_3(part_1, part_2, part_3) LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_2(part_1, part_2), part_3) -#define LB_DETAIL_JOIN_4(part_1, part_2, part_3, part_4) \ - LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_3(part_1, part_2, part_3), part_4) -#define LB_DETAIL_JOIN_5(part_1, part_2, part_3, part_4, part_5) \ - LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_4(part_1, part_2, part_3, part_4), part_5) -#define LB_DETAIL_JOIN_6(part_1, part_2, part_3, part_4, part_5, part_6) \ - LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_5(part_1, part_2, part_3, part_4, part_5), part_6) -#define LB_DETAIL_JOIN_7(part_1, part_2, part_3, part_4, part_5, part_6, part_7) \ - LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_6(part_1, part_2, part_3, part_4, part_5, part_6), part_7) -#define LB_DETAIL_JOIN_8(part_1, part_2, part_3, part_4, part_5, part_6, part_7, part_8) \ - LB_DETAIL_JOIN_2(LB_DETAIL_JOIN_7(part_1, part_2, part_3, part_4, part_5, part_6, part_7), part_8) - -#define LB_DETAIL_GET_9TH_ARG(_1, _2, _3, _4, _5, _6, _7, _8, _9, ...) _9 -#define LB_DETAIL_COUNT_ARGS(...) LB_DETAIL_GET_9TH_ARG(__VA_ARGS__, 8, 7, 6, 5, 4, 3, 2, 1) -#define LB_DETAIL_JOIN_DISPATCH(count, ...) LB_DETAIL_JOIN_DISPATCH_IMPL(count, __VA_ARGS__) -#define LB_DETAIL_JOIN_DISPATCH_IMPL(count, ...) LB_DETAIL_JOIN_##count(__VA_ARGS__) -/** @endcond */ - -/** - * @brief Concatenate variadic macro arguments with underscore separators. - */ -#define LB_CAT_VA_ARGS(...) LB_DETAIL_JOIN_DISPATCH(LB_DETAIL_COUNT_ARGS(__VA_ARGS__), __VA_ARGS__) - -/** - * @brief Create a test name from multiple macro arguments. - * - * Usage: - * @code - * #define TEST_S(...) LB_MAKE_TEST(TEST, MySuite, __VA_ARGS__) - * TEST_S(Does, The, Thing) {} - * @endcode - */ -#define LB_MAKE_TEST(test_macro, test_suite_name, ...) test_macro(test_suite_name, LB_CAT_VA_ARGS(__VA_ARGS__)) - #if !defined(LIZARDBYTE_COMMON_TESTING_KEEP_GTEST_TEST) #undef TEST // NOSONAR(cpp:S959): Tests intentionally wrap TEST to use BaseTest. From 19e536d6c61b937cc25edc118bbf573250ac600f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:23:08 -0400 Subject: [PATCH 09/10] Tag Python Codecov reports per OS Update the Codecov flags for Python coverage and test uploads to use `RunnerOS-python`, separating them from other language reports. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cdcbec..8f9e2e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: disable_search: true fail_ci_if_error: true files: ./coverage/python-coverage.xml - flags: ${{ runner.os }} + flags: ${{ runner.os }}-python report_type: coverage token: ${{ secrets.CODECOV_TOKEN }} verbose: true @@ -105,7 +105,7 @@ jobs: disable_search: true fail_ci_if_error: true files: ./junit-python.xml - flags: ${{ runner.os }} + flags: ${{ runner.os }}-python report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true From 75423bbea9df12ee692aac01a02aa927a8cd5c3d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:34:56 -0400 Subject: [PATCH 10/10] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 1815345..c84ea95 100644 --- a/README.md +++ b/README.md @@ -190,3 +190,8 @@ cmake -DBUILD_DOCS=OFF -DBUILD_TESTS=ON -B build -S . cmake --build build --parallel ctest --test-dir build --output-on-failure ``` + +
+ + [TOC] +