From bf3ff3f7cbd7ad8dabe2709aa39ae9af1a21d4e8 Mon Sep 17 00:00:00 2001 From: Iuri Matias Date: Thu, 2 Apr 2026 16:49:47 -0400 Subject: [PATCH] test framework for logos modules --- README.md | 216 ++++++++++++++ cmake/LogosTest.cmake | 220 +++++++++++++++ examples/basic-module-test/CMakeLists.txt | 15 + examples/basic-module-test/main.cpp | 3 + examples/basic-module-test/test_example.cpp | 66 +++++ examples/extlib-module-test/CMakeLists.txt | 15 + examples/extlib-module-test/main.cpp | 3 + .../extlib-module-test/mocks/mock_libcalc.cpp | 15 + examples/extlib-module-test/test_extlib.cpp | 41 +++ examples/ipc-module-test/CMakeLists.txt | 18 ++ examples/ipc-module-test/main.cpp | 3 + examples/ipc-module-test/test_ipc.cpp | 80 ++++++ flake.nix | 81 ++++++ include/logos_clib_mock.h | 121 ++++++++ include/logos_test.h | 241 ++++++++++++++++ include/logos_test_context.h | 146 ++++++++++ nix/mkLogosModuleTests.nix | 117 ++++++++ src/logos_clib_mock.cpp | 121 ++++++++ src/logos_test_context.cpp | 263 ++++++++++++++++++ src/logos_test_runner.cpp | 225 +++++++++++++++ 20 files changed, 2010 insertions(+) create mode 100644 README.md create mode 100644 cmake/LogosTest.cmake create mode 100644 examples/basic-module-test/CMakeLists.txt create mode 100644 examples/basic-module-test/main.cpp create mode 100644 examples/basic-module-test/test_example.cpp create mode 100644 examples/extlib-module-test/CMakeLists.txt create mode 100644 examples/extlib-module-test/main.cpp create mode 100644 examples/extlib-module-test/mocks/mock_libcalc.cpp create mode 100644 examples/extlib-module-test/test_extlib.cpp create mode 100644 examples/ipc-module-test/CMakeLists.txt create mode 100644 examples/ipc-module-test/main.cpp create mode 100644 examples/ipc-module-test/test_ipc.cpp create mode 100644 flake.nix create mode 100644 include/logos_clib_mock.h create mode 100644 include/logos_test.h create mode 100644 include/logos_test_context.h create mode 100644 nix/mkLogosModuleTests.nix create mode 100644 src/logos_clib_mock.cpp create mode 100644 src/logos_test_context.cpp create mode 100644 src/logos_test_runner.cpp diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8f5998 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# Logos Test Framework + +Unit testing framework for Logos modules. Supports mocking calls to other modules and to libraries. + +## Features + +- **Module mocking** — mock calls to other Logos modules with a fluent API +- **C library mocking** — link-time substitution for external C/C++ libraries +- **Event testing** — capture and assert on events emitted by the module +- **Color terminal output** — auto-detects TTY, clean pass/fail formatting +- **JSON output** — `--json` flag for CI and agent consumption +- **Test filtering** — `--filter ` to run a subset of tests +- **10-line CMake** — `logos_test()` replaces ~150 lines of boilerplate +- **Nix integration** — `mkLogosModuleTests` for zero-config builds + +## Quick Start + +### 1. Write tests + +```cpp +// tests/test_my_feature.cpp +#include +#include "my_module_impl.h" + +LOGOS_TEST(feature_returns_expected_value) { + auto t = LogosTestContext("my_module"); + t.mockModule("other_module", "getData").returns(42); + + MyModuleImpl impl; + t.init(&impl); + + auto result = impl.fetchData(); + LOGOS_ASSERT_EQ(result, 42); + LOGOS_ASSERT(t.moduleCalled("other_module", "getData")); +} +``` + +```cpp +// tests/main.cpp +#include +LOGOS_TEST_MAIN() +``` + +### 2. CMake (tests/CMakeLists.txt) + +```cmake +cmake_minimum_required(VERSION 3.14) +project(MyModuleTests LANGUAGES CXX) + +include(LogosTest) + +logos_test( + NAME my_module_tests + MODULE_SOURCES ../src/my_module_impl.cpp + TEST_SOURCES + main.cpp + test_my_feature.cpp +) +``` + +### 3. Run + +```bash +./my_module_tests # colored output +./my_module_tests --json # machine-readable +./my_module_tests --filter feature # run matching tests +``` + +## Mocking Other Modules + +```cpp +LOGOS_TEST(calls_waku_module) { + auto t = LogosTestContext("chat_module"); + t.mockModule("waku_module", "relayPublish").returns(true); + + ChatImpl impl; + t.init(&impl); + + impl.sendMessage("hello"); + LOGOS_ASSERT(t.moduleCalled("waku_module", "relayPublish")); + LOGOS_ASSERT_EQ(t.moduleCallCount("waku_module", "relayPublish"), 1); +} +``` + +## Mocking C Libraries + +### 1. Write mock stubs (`tests/mocks/mock_libcalc.cpp`) + +```cpp +#include +extern "C" { #include "libcalc.h" } + +extern "C" int calc_add(int a, int b) { + LOGOS_CMOCK_RECORD("calc_add"); + return LOGOS_CMOCK_RETURN(int, "calc_add"); +} + +extern "C" const char* calc_version() { + LOGOS_CMOCK_RECORD("calc_version"); + return LOGOS_CMOCK_RETURN_STRING("calc_version"); +} +``` + +### 2. Reference in CMake + +```cmake +logos_test( + NAME calc_module_tests + MODULE_SOURCES ../src/calc_module_impl.cpp + TEST_SOURCES main.cpp test_calc.cpp + MOCK_C_SOURCES mocks/mock_libcalc.cpp +) +``` + +### 3. Use in tests + +```cpp +LOGOS_TEST(add_returns_mocked_value) { + auto t = LogosTestContext("calc_module"); + t.mockCFunction("calc_add").returns(99); + + CalcModuleImpl impl; + t.init(&impl); + + auto result = impl.add(10, 20); + LOGOS_ASSERT_EQ(result, 99); + LOGOS_ASSERT(t.cFunctionCalled("calc_add")); +} +``` + +## Event Testing + +```cpp +LOGOS_TEST(method_emits_event) { + auto t = LogosTestContext("my_module"); + t.captureEvents(); + + MyModuleImpl impl; + t.init(&impl); + + impl.doSomething("data"); + + LOGOS_ASSERT(t.eventEmitted("myEvent")); + LOGOS_ASSERT_EQ(t.eventCount("myEvent"), 1); + LOGOS_ASSERT_EQ(t.lastEventData("myEvent").at(0).toString(), "data"); +} +``` + +## Assertions + +| Macro | Description | +|-------|-------------| +| `LOGOS_ASSERT(expr)` | Expression is truthy | +| `LOGOS_ASSERT_TRUE(expr)` | Alias for LOGOS_ASSERT | +| `LOGOS_ASSERT_FALSE(expr)` | Expression is falsy | +| `LOGOS_ASSERT_EQ(a, b)` | `a == b` with diff on failure | +| `LOGOS_ASSERT_NE(a, b)` | `a != b` | +| `LOGOS_ASSERT_GT(a, b)` | `a > b` | +| `LOGOS_ASSERT_GE(a, b)` | `a >= b` | +| `LOGOS_ASSERT_LT(a, b)` | `a < b` | +| `LOGOS_ASSERT_LE(a, b)` | `a <= b` | +| `LOGOS_ASSERT_CONTAINS(h, n)` | String `h` contains `n` | +| `LOGOS_ASSERT_THROWS(expr)` | Expression throws | + +## Terminal Output + +``` + ── my_module_tests ───────────────────────── + + PASS feature_returns_expected_value 2ms + PASS handles_empty_input 1ms + FAIL handles_error_case 3ms + ASSERT_EQ failed: expected [true] got [false] + at test_my_feature.cpp:42 + + ── Results: 2 passed, 1 failed (6ms) ────── +``` + +## Nix Integration + +### In module's flake.nix + +```nix +checks.${system}.unit-tests = logos-test-framework.lib.mkLogosModuleTests { + inherit pkgs; + src = ./.; + testDir = ./tests; + configFile = ./metadata.json; + logosSdk = logos-cpp-sdk.packages.${system}.default; + testFramework = logos-test-framework.packages.${system}.default; +}; +``` + +### Via logos-module-builder (ultimate goal) + +```nix +logos-module-builder.lib.mkLogosModule { + src = ./.; + configFile = ./metadata.json; + flakeInputs = inputs; + tests = { + dir = ./tests; + mockCLibs = ["gowalletsdk"]; + }; +}; +``` + +## CLI Options + +``` +./my_module_tests [options] + --filter Run only tests whose name contains pattern + --json Output results as JSON (for CI/agents) + --no-color Disable colored output + --help Show help +``` diff --git a/cmake/LogosTest.cmake b/cmake/LogosTest.cmake new file mode 100644 index 0000000..cccc5a9 --- /dev/null +++ b/cmake/LogosTest.cmake @@ -0,0 +1,220 @@ +# LogosTest.cmake +# Provides logos_test() — a single CMake function that builds a complete +# Logos module test executable with mocking support and minimal boilerplate. +# +# Usage: +# include(LogosTest) +# +# logos_test( +# NAME my_module_tests +# MODULE_SOURCES ../src/my_module_impl.cpp +# TEST_SOURCES +# main.cpp +# test_feature_a.cpp +# test_feature_b.cpp +# MOCK_C_SOURCES # optional: C lib mock stubs +# mocks/mock_libcalc.cpp +# EXTRA_INCLUDES # optional: additional include dirs +# ../lib +# GENERATED_SOURCES # optional: generated dispatch code +# ../logos_provider_dispatch.cpp +# GENERATED_DIR # optional: generated code dir +# ../generated_code +# ) + +cmake_minimum_required(VERSION 3.14) + +#[=======================================================================[.rst: +logos_test +---------- + +Build a Logos module test executable. + +Required: + NAME - Test executable name + MODULE_SOURCES - Module source files to compile (not the real C lib) + TEST_SOURCES - Test source files (main.cpp + test_*.cpp) + +Optional: + MOCK_C_SOURCES - Mock implementations for C libraries + EXTRA_INCLUDES - Additional include directories + GENERATED_SOURCES - Generated code files (logos_provider_dispatch.cpp, etc.) + GENERATED_DIR - Directory containing generated code (logos_sdk.cpp, etc.) + EXTRA_LINK_LIBS - Additional libraries to link +#]=======================================================================] +function(logos_test) + cmake_parse_arguments(LT "" + "NAME;GENERATED_DIR" + "MODULE_SOURCES;TEST_SOURCES;MOCK_C_SOURCES;EXTRA_INCLUDES;GENERATED_SOURCES;EXTRA_LINK_LIBS" + ${ARGN}) + + if(NOT LT_NAME) + message(FATAL_ERROR "logos_test: NAME is required") + endif() + + set(CMAKE_CXX_STANDARD 17) + set(CMAKE_CXX_STANDARD_REQUIRED ON) + set(CMAKE_AUTOMOC ON) + + # ── Locate logos-test-framework ────────────────────────────────────────── + + if(NOT DEFINED LOGOS_TEST_FRAMEWORK_ROOT) + if(DEFINED ENV{LOGOS_TEST_FRAMEWORK_ROOT}) + set(LOGOS_TEST_FRAMEWORK_ROOT "$ENV{LOGOS_TEST_FRAMEWORK_ROOT}") + else() + message(FATAL_ERROR "LOGOS_TEST_FRAMEWORK_ROOT not set. " + "Set it via environment or CMake variable.") + endif() + endif() + + # ── Locate logos-cpp-sdk ───────────────────────────────────────────────── + + if(NOT DEFINED LOGOS_CPP_SDK_ROOT) + if(DEFINED ENV{LOGOS_CPP_SDK_ROOT}) + set(LOGOS_CPP_SDK_ROOT "$ENV{LOGOS_CPP_SDK_ROOT}") + else() + message(FATAL_ERROR "LOGOS_CPP_SDK_ROOT not set. " + "Set it via environment or CMake variable.") + endif() + endif() + + # Detect source vs installed layout for SDK + if(EXISTS "${LOGOS_CPP_SDK_ROOT}/cpp/logos_api.h") + set(LOGOS_CPP_SDK_IS_SOURCE TRUE) + set(SDK_INCLUDE "${LOGOS_CPP_SDK_ROOT}/cpp") + set(SDK_MOCK_INCLUDE "${LOGOS_CPP_SDK_ROOT}/cpp/implementations/mock") + else() + set(LOGOS_CPP_SDK_IS_SOURCE FALSE) + set(SDK_INCLUDE "${LOGOS_CPP_SDK_ROOT}/include/cpp") + set(SDK_MOCK_INCLUDE "${LOGOS_CPP_SDK_ROOT}/include/cpp/implementations/mock") + endif() + + # Detect interface.h location (core/interface.h in SDK) + if(EXISTS "${LOGOS_CPP_SDK_ROOT}/core/interface.h") + set(SDK_CORE_INCLUDE "${LOGOS_CPP_SDK_ROOT}/core") + elseif(EXISTS "${LOGOS_CPP_SDK_ROOT}/include/core/interface.h") + set(SDK_CORE_INCLUDE "${LOGOS_CPP_SDK_ROOT}/include/core") + else() + set(SDK_CORE_INCLUDE "${SDK_INCLUDE}") + endif() + + message(STATUS "[LogosTest] SDK root: ${LOGOS_CPP_SDK_ROOT} (source=${LOGOS_CPP_SDK_IS_SOURCE})") + message(STATUS "[LogosTest] Framework root: ${LOGOS_TEST_FRAMEWORK_ROOT}") + + # ── Qt ─────────────────────────────────────────────────────────────────── + + find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core RemoteObjects) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core RemoteObjects) + + # ── Collect sources ────────────────────────────────────────────────────── + + set(ALL_SOURCES + ${LT_TEST_SOURCES} + ${LT_MODULE_SOURCES} + ${LT_MOCK_C_SOURCES} + ${LT_GENERATED_SOURCES} + # Framework implementation sources + ${LOGOS_TEST_FRAMEWORK_ROOT}/src/logos_test_runner.cpp + ${LOGOS_TEST_FRAMEWORK_ROOT}/src/logos_test_context.cpp + ${LOGOS_TEST_FRAMEWORK_ROOT}/src/logos_clib_mock.cpp + ) + + # SDK sources (when using source layout — Nix always provides installed layout) + if(LOGOS_CPP_SDK_IS_SOURCE) + list(APPEND ALL_SOURCES + ${SDK_INCLUDE}/logos_types.cpp + ${SDK_INCLUDE}/logos_api.cpp + ${SDK_INCLUDE}/logos_api_client.cpp + ${SDK_INCLUDE}/logos_api_consumer.cpp + ${SDK_INCLUDE}/logos_api_provider.cpp + ${SDK_INCLUDE}/module_proxy.cpp + ${SDK_INCLUDE}/token_manager.cpp + ${SDK_INCLUDE}/logos_transport_factory.cpp + ${SDK_INCLUDE}/logos_registry_factory.cpp + ${SDK_INCLUDE}/logos_provider_object.cpp + ${SDK_INCLUDE}/qt_provider_object.cpp + ${SDK_INCLUDE}/implementations/qt_local/local_transport.cpp + ${SDK_INCLUDE}/implementations/qt_remote/remote_transport.cpp + ${SDK_INCLUDE}/implementations/qt_remote/qt_remote_registry.cpp + ${SDK_INCLUDE}/implementations/mock/mock_store.cpp + ${SDK_INCLUDE}/implementations/mock/mock_transport.cpp + ) + endif() + + # Look for generated logos_sdk.cpp in GENERATED_DIR + if(LT_GENERATED_DIR) + if(EXISTS "${LT_GENERATED_DIR}/logos_sdk.cpp") + list(APPEND ALL_SOURCES "${LT_GENERATED_DIR}/logos_sdk.cpp") + set_source_files_properties("${LT_GENERATED_DIR}/logos_sdk.cpp" + PROPERTIES SKIP_AUTOMOC ON) + elseif(EXISTS "${LT_GENERATED_DIR}/include/logos_sdk.cpp") + list(APPEND ALL_SOURCES "${LT_GENERATED_DIR}/include/logos_sdk.cpp") + set_source_files_properties("${LT_GENERATED_DIR}/include/logos_sdk.cpp" + PROPERTIES SKIP_AUTOMOC ON) + endif() + endif() + + # Skip AUTOMOC on generated dispatch files + foreach(src ${LT_GENERATED_SOURCES}) + set_source_files_properties(${src} PROPERTIES SKIP_AUTOMOC ON) + endforeach() + + # ── Build test executable ──────────────────────────────────────────────── + + add_executable(${LT_NAME} ${ALL_SOURCES}) + + target_compile_definitions(${LT_NAME} PRIVATE LOGOS_TESTING=1) + + target_include_directories(${LT_NAME} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../src + ${CMAKE_CURRENT_BINARY_DIR} + # Framework headers + ${LOGOS_TEST_FRAMEWORK_ROOT}/include + # SDK headers + ${SDK_INCLUDE} + ${SDK_CORE_INCLUDE} + ${SDK_MOCK_INCLUDE} + ${SDK_INCLUDE}/implementations/qt_local + ${SDK_INCLUDE}/implementations/qt_remote + ) + + # Generated code include + if(LT_GENERATED_DIR) + target_include_directories(${LT_NAME} PRIVATE + ${LT_GENERATED_DIR} + ${LT_GENERATED_DIR}/include + ) + endif() + + # Extra includes + foreach(dir ${LT_EXTRA_INCLUDES}) + target_include_directories(${LT_NAME} PRIVATE ${dir}) + endforeach() + + # ── Link ───────────────────────────────────────────────────────────────── + + target_link_libraries(${LT_NAME} PRIVATE + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::RemoteObjects + ) + + # In installed layout, link against pre-built SDK library + if(NOT LOGOS_CPP_SDK_IS_SOURCE) + find_library(LOGOS_SDK_LIB logos_sdk + PATHS "${LOGOS_CPP_SDK_ROOT}/lib" NO_DEFAULT_PATH REQUIRED) + target_link_libraries(${LT_NAME} PRIVATE ${LOGOS_SDK_LIB}) + endif() + + # Extra link libraries + foreach(lib ${LT_EXTRA_LINK_LIBS}) + target_link_libraries(${LT_NAME} PRIVATE ${lib}) + endforeach() + + # ── CTest ──────────────────────────────────────────────────────────────── + + enable_testing() + add_test(NAME ${LT_NAME} COMMAND ${LT_NAME}) + +endfunction() diff --git a/examples/basic-module-test/CMakeLists.txt b/examples/basic-module-test/CMakeLists.txt new file mode 100644 index 0000000..f1fd6d1 --- /dev/null +++ b/examples/basic-module-test/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.14) +project(BasicModuleTest LANGUAGES CXX) + +# The framework's LogosTest.cmake is found via CMAKE_MODULE_PATH +# (set by Nix or by the developer) +include(LogosTest) + +logos_test( + NAME basic_module_tests + MODULE_SOURCES + # In a real module: ../src/my_module_impl.cpp + TEST_SOURCES + main.cpp + test_example.cpp +) diff --git a/examples/basic-module-test/main.cpp b/examples/basic-module-test/main.cpp new file mode 100644 index 0000000..93a8096 --- /dev/null +++ b/examples/basic-module-test/main.cpp @@ -0,0 +1,3 @@ +#include + +LOGOS_TEST_MAIN() diff --git a/examples/basic-module-test/test_example.cpp b/examples/basic-module-test/test_example.cpp new file mode 100644 index 0000000..1723ddb --- /dev/null +++ b/examples/basic-module-test/test_example.cpp @@ -0,0 +1,66 @@ +// Example: Testing a module that calls another Logos module. +// The module under test would normally live in ../src/, but this +// is a self-contained example showing the test API. + +#include + +// Simulated module method that would call another module +static int addViaRemote(LogosTestContext& t) { + // In real code this would be impl.callBasicAddInts(10, 20) + // which internally calls m_basicClient->invokeRemoteMethod(...) + // The mock intercepts at the transport layer. + return 30; // placeholder — real tests use actual module impl +} + +LOGOS_TEST(mock_module_returns_configured_value) { + auto t = LogosTestContext("my_module"); + t.mockModule("other_module", "addInts").returns(42); + // In a real test: MyModuleImpl impl; t.init(&impl); + // result = impl.callOtherAddInts(10, 20); + // LOGOS_ASSERT_EQ(result, 42); + + // For this example, just verify the API compiles and works + LOGOS_ASSERT(true); +} + +LOGOS_TEST(mock_module_records_calls) { + auto t = LogosTestContext("my_module"); + t.mockModule("dep_module", "echo").returns("hello back"); + + // Verify initial state + LOGOS_ASSERT_FALSE(t.moduleCalled("dep_module", "echo")); + LOGOS_ASSERT_EQ(t.moduleCallCount("dep_module", "echo"), 0); +} + +LOGOS_TEST(c_mock_returns_configured_value) { + auto t = LogosTestContext("my_module"); + t.mockCFunction("calc_add").returns(42); + + int result = LogosCMockStore::instance().getReturn("calc_add"); + LOGOS_ASSERT_EQ(result, 42); +} + +LOGOS_TEST(c_mock_records_calls) { + auto t = LogosTestContext("my_module"); + t.mockCFunction("calc_add").returns(0); + + LogosCMockStore::instance().recordCall("calc_add"); + + LOGOS_ASSERT(t.cFunctionCalled("calc_add")); + LOGOS_ASSERT_EQ(t.cFunctionCallCount("calc_add"), 1); +} + +LOGOS_TEST(assertions_work_correctly) { + LOGOS_ASSERT_TRUE(1 == 1); + LOGOS_ASSERT_FALSE(1 == 2); + LOGOS_ASSERT_EQ(42, 42); + LOGOS_ASSERT_NE(1, 2); + LOGOS_ASSERT_GT(10, 5); + LOGOS_ASSERT_GE(10, 10); + LOGOS_ASSERT_LT(5, 10); + LOGOS_ASSERT_LE(10, 10); +} + +LOGOS_TEST(assert_throws_catches_exception) { + LOGOS_ASSERT_THROWS(throw std::runtime_error("expected")); +} diff --git a/examples/extlib-module-test/CMakeLists.txt b/examples/extlib-module-test/CMakeLists.txt new file mode 100644 index 0000000..1750525 --- /dev/null +++ b/examples/extlib-module-test/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.14) +project(ExtlibModuleTest LANGUAGES CXX) + +include(LogosTest) + +logos_test( + NAME extlib_module_tests + MODULE_SOURCES + # In real module: ../src/calc_module_impl.cpp + TEST_SOURCES + main.cpp + test_extlib.cpp + MOCK_C_SOURCES + mocks/mock_libcalc.cpp +) diff --git a/examples/extlib-module-test/main.cpp b/examples/extlib-module-test/main.cpp new file mode 100644 index 0000000..93a8096 --- /dev/null +++ b/examples/extlib-module-test/main.cpp @@ -0,0 +1,3 @@ +#include + +LOGOS_TEST_MAIN() diff --git a/examples/extlib-module-test/mocks/mock_libcalc.cpp b/examples/extlib-module-test/mocks/mock_libcalc.cpp new file mode 100644 index 0000000..b6a83a4 --- /dev/null +++ b/examples/extlib-module-test/mocks/mock_libcalc.cpp @@ -0,0 +1,15 @@ +// Mock implementation of libcalc — replaces the real C library at link time. +// Each function records its call and returns the value configured via +// LogosTestContext::mockCFunction(). + +#include + +extern "C" int calc_add(int a, int b) { + LOGOS_CMOCK_RECORD("calc_add"); + return LOGOS_CMOCK_RETURN(int, "calc_add"); +} + +extern "C" const char* calc_version() { + LOGOS_CMOCK_RECORD("calc_version"); + return LOGOS_CMOCK_RETURN_STRING("calc_version"); +} diff --git a/examples/extlib-module-test/test_extlib.cpp b/examples/extlib-module-test/test_extlib.cpp new file mode 100644 index 0000000..9996c00 --- /dev/null +++ b/examples/extlib-module-test/test_extlib.cpp @@ -0,0 +1,41 @@ +// Example: Testing a module that wraps a C library. +// The C library is mocked at link time — mock stubs in mocks/ replace +// the real library functions. + +#include + +// Pretend this is the module's C library header +extern "C" { + int calc_add(int a, int b); + const char* calc_version(); +} + +LOGOS_TEST(calc_add_returns_mocked_value) { + auto t = LogosTestContext("calc_module"); + t.mockCFunction("calc_add").returns(99); + + // When the module calls calc_add(), it gets the mocked value + int result = calc_add(10, 20); + LOGOS_ASSERT_EQ(result, 99); + LOGOS_ASSERT(t.cFunctionCalled("calc_add")); +} + +LOGOS_TEST(calc_version_returns_mocked_string) { + auto t = LogosTestContext("calc_module"); + t.mockCFunction("calc_version").returns("mock-1.0"); + + const char* ver = calc_version(); + LOGOS_ASSERT_EQ(std::string(ver), std::string("mock-1.0")); + LOGOS_ASSERT(t.cFunctionCalled("calc_version")); +} + +LOGOS_TEST(calc_functions_track_call_count) { + auto t = LogosTestContext("calc_module"); + t.mockCFunction("calc_add").returns(0); + + calc_add(1, 2); + calc_add(3, 4); + calc_add(5, 6); + + LOGOS_ASSERT_EQ(t.cFunctionCallCount("calc_add"), 3); +} diff --git a/examples/ipc-module-test/CMakeLists.txt b/examples/ipc-module-test/CMakeLists.txt new file mode 100644 index 0000000..e9105ca --- /dev/null +++ b/examples/ipc-module-test/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.14) +project(IpcModuleTest LANGUAGES CXX) + +include(LogosTest) + +logos_test( + NAME ipc_module_tests + MODULE_SOURCES + # In real module: ../src/test_ipc_new_api_impl.cpp + # ../logos_provider_dispatch.cpp + TEST_SOURCES + main.cpp + test_ipc.cpp + # GENERATED_SOURCES + # ../logos_provider_dispatch.cpp + # GENERATED_DIR + # ../generated_code +) diff --git a/examples/ipc-module-test/main.cpp b/examples/ipc-module-test/main.cpp new file mode 100644 index 0000000..93a8096 --- /dev/null +++ b/examples/ipc-module-test/main.cpp @@ -0,0 +1,3 @@ +#include + +LOGOS_TEST_MAIN() diff --git a/examples/ipc-module-test/test_ipc.cpp b/examples/ipc-module-test/test_ipc.cpp new file mode 100644 index 0000000..87a6b60 --- /dev/null +++ b/examples/ipc-module-test/test_ipc.cpp @@ -0,0 +1,80 @@ +// Example: Testing a module that calls other Logos modules via IPC. +// Uses LogosTestContext to mock remote module calls. +// This mirrors what test-ipc-module-new-api does, but with the new framework. + +#include + +// In a real test, you'd include the module's impl header: +// #include "test_ipc_new_api_impl.h" + +LOGOS_TEST(mock_basic_addInts) { + auto t = LogosTestContext("test_ipc_module"); + t.mockModule("test_basic_module", "addInts").returns(30); + + // With real module: + // TestIpcNewApiImpl impl; + // t.init(&impl); + // int result = impl.callBasicAddInts(10, 20); + // LOGOS_ASSERT_EQ(result, 30); + + // Verify mock API works + LOGOS_ASSERT_FALSE(t.moduleCalled("test_basic_module", "addInts")); +} + +LOGOS_TEST(mock_extlib_reverse) { + auto t = LogosTestContext("test_ipc_module"); + t.mockModule("test_extlib_module", "reverseString").returns("olleh"); + + // With real module: + // TestIpcNewApiImpl impl; + // t.init(&impl); + // QString result = impl.callExtlibReverse("hello"); + // LOGOS_ASSERT_EQ(result, QString("olleh")); + + LOGOS_ASSERT_EQ(t.moduleCallCount("test_extlib_module", "reverseString"), 0); +} + +LOGOS_TEST(mock_chained_calls) { + auto t = LogosTestContext("test_ipc_module"); + t.mockModule("test_basic_module", "echo").returns("hello"); + t.mockModule("test_extlib_module", "reverseString").returns("olleh"); + + // With real module: + // TestIpcNewApiImpl impl; + // t.init(&impl); + // QString result = impl.chainEchoThenReverse("hello"); + // LOGOS_ASSERT_EQ(result, QString("olleh")); + // LOGOS_ASSERT(t.moduleCalled("test_basic_module", "echo")); + // LOGOS_ASSERT(t.moduleCalled("test_extlib_module", "reverseString")); + + LOGOS_ASSERT(true); // API compiles correctly +} + +LOGOS_TEST(event_capture_api) { + auto t = LogosTestContext("test_ipc_module"); + t.captureEvents(); + + // With real module: + // TestIpcNewApiImpl impl; + // t.init(&impl); + // impl.triggerBasicEvent("data"); + // LOGOS_ASSERT(t.eventEmitted("triggeredBasicEvent")); + // LOGOS_ASSERT_EQ(t.eventCount("triggeredBasicEvent"), 1); + + // Verify event API compiles + LOGOS_ASSERT_FALSE(t.eventEmitted("nonexistent")); + LOGOS_ASSERT_EQ(t.eventCount("nonexistent"), 0); +} + +LOGOS_TEST(context_per_test_isolation) { + // Each LogosTestContext resets all mocks and state + auto t = LogosTestContext("module_a"); + t.mockCFunction("some_func").returns(100); + LogosCMockStore::instance().recordCall("some_func"); + LOGOS_ASSERT(t.cFunctionCalled("some_func")); + + // Create a new context — state should be fresh + auto t2 = LogosTestContext("module_b"); + LOGOS_ASSERT_FALSE(t2.cFunctionCalled("some_func")); + LOGOS_ASSERT_EQ(t2.cFunctionCallCount("some_func"), 0); +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5f98768 --- /dev/null +++ b/flake.nix @@ -0,0 +1,81 @@ +{ + description = "Logos Test Framework — unit testing for Logos modules without Qt boilerplate"; + + inputs = { + logos-nix.url = "github:logos-co/logos-nix"; + logos-cpp-sdk.url = "github:logos-co/logos-cpp-sdk"; + nixpkgs.follows = "logos-nix/nixpkgs"; + }; + + outputs = { self, nixpkgs, logos-nix, logos-cpp-sdk, ... }: + let + systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ]; + + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f { + inherit system; + pkgs = import nixpkgs { inherit system; }; + }); + in + { + # Library functions for building module tests + lib = { + mkLogosModuleTests = args: import ./nix/mkLogosModuleTests.nix args; + }; + + # The framework as a package (headers + cmake + sources) + packages = forAllSystems ({ pkgs, system, ... }: + let + logosSdk = logos-cpp-sdk.packages.${system}.default; + + frameworkPkg = pkgs.stdenv.mkDerivation { + pname = "logos-test-framework"; + version = "0.1.0"; + src = ./.; + + # No build step — just install headers, cmake, and sources + dontBuild = true; + + installPhase = '' + mkdir -p $out/include $out/cmake $out/src + + cp include/*.h $out/include/ + cp cmake/*.cmake $out/cmake/ + cp src/*.cpp $out/src/ + ''; + + meta = with pkgs.lib; { + description = "Logos Module Test Framework"; + license = licenses.mit; + }; + }; + in { + default = frameworkPkg; + } + ); + + # Development shell for working on the framework + devShells = forAllSystems ({ pkgs, system, ... }: + let + logosSdk = logos-cpp-sdk.packages.${system}.default; + in { + default = pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + cmake + pkg-config + qt6.wrapQtAppsHook + ]; + buildInputs = with pkgs; [ + qt6.qtbase + qt6.qtremoteobjects + logosSdk + ]; + shellHook = '' + export LOGOS_CPP_SDK_ROOT="${logosSdk}" + export LOGOS_TEST_FRAMEWORK_ROOT="${./.}" + echo "Logos Test Framework development environment" + ''; + }; + } + ); + }; +} diff --git a/include/logos_clib_mock.h b/include/logos_clib_mock.h new file mode 100644 index 0000000..0784613 --- /dev/null +++ b/include/logos_clib_mock.h @@ -0,0 +1,121 @@ +#ifndef LOGOS_CLIB_MOCK_H +#define LOGOS_CLIB_MOCK_H + +/** + * @file logos_clib_mock.h + * @brief C library function mock store for link-time substitution. + * + * When building tests, the real C library is NOT linked. Instead, mock source + * files provide the same function signatures backed by LogosCMockStore. + * + * Example mock file (mocks/mock_libcalc.cpp): + * + * #include + * extern "C" { #include "libcalc.h" } + * + * extern "C" int calc_add(int a, int b) { + * LOGOS_CMOCK_RECORD("calc_add"); + * return LOGOS_CMOCK_RETURN(int, "calc_add"); + * } + * + * In tests: + * + * LOGOS_TEST(add_works) { + * auto t = LogosTestContext("my_module"); + * t.mockCFunction("calc_add").returns(42); + * // ... use module that calls calc_add() ... + * } + */ + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// LogosCMockStore — singleton registry for C function mocks +// --------------------------------------------------------------------------- + +class LogosCMockStore { +public: + static LogosCMockStore& instance(); + + void reset(); + + // -- Expectation setup ---------------------------------------------------- + + class ExpectationBuilder { + public: + explicit ExpectationBuilder(const std::string& funcName); + + ExpectationBuilder& returns(int value); + ExpectationBuilder& returns(bool value); + ExpectationBuilder& returns(double value); + ExpectationBuilder& returns(const char* value); + ExpectationBuilder& returns(const std::string& value); + ExpectationBuilder& returnsPtr(const void* ptr); + ExpectationBuilder& returnsRaw(const void* data, size_t size); + + private: + std::string m_funcName; + }; + + ExpectationBuilder when(const std::string& funcName); + + // -- Call recording (used by mock implementations) ------------------------ + + void recordCall(const std::string& funcName); + + template + T getReturn(const std::string& funcName) const { + std::lock_guard lock(m_mutex); + auto it = m_returns.find(funcName); + if (it == m_returns.end()) return T{}; + + const auto& data = it->second; + if (data.size() < sizeof(T)) return T{}; + + T val; + std::memcpy(&val, data.data(), sizeof(T)); + return val; + } + + // Specialization for const char* — returns a pointer stored as uintptr_t + const char* getReturnString(const std::string& funcName) const; + + // -- Verification --------------------------------------------------------- + + bool wasCalled(const std::string& funcName) const; + int callCount(const std::string& funcName) const; + + // -- Storage (low-level) -------------------------------------------------- + + void setReturn(const std::string& funcName, const void* data, size_t size); + void setReturnPtr(const std::string& funcName, const void* ptr); + +private: + LogosCMockStore() = default; + LogosCMockStore(const LogosCMockStore&) = delete; + LogosCMockStore& operator=(const LogosCMockStore&) = delete; + + mutable std::mutex m_mutex; + std::unordered_map> m_returns; + std::unordered_map m_calls; +}; + +// --------------------------------------------------------------------------- +// Convenience macros for mock implementations +// --------------------------------------------------------------------------- + +#define LOGOS_CMOCK_RECORD(funcName) \ + LogosCMockStore::instance().recordCall(funcName) + +#define LOGOS_CMOCK_RETURN(type, funcName) \ + LogosCMockStore::instance().getReturn(funcName) + +#define LOGOS_CMOCK_RETURN_STRING(funcName) \ + LogosCMockStore::instance().getReturnString(funcName) + +#endif // LOGOS_CLIB_MOCK_H diff --git a/include/logos_test.h b/include/logos_test.h new file mode 100644 index 0000000..b06378a --- /dev/null +++ b/include/logos_test.h @@ -0,0 +1,241 @@ +#ifndef LOGOS_TEST_H +#define LOGOS_TEST_H + +/** + * @file logos_test.h + * @brief Logos Module Test Framework — write unit tests without Qt boilerplate. + * + * Single include for the full test API: runner, assertions, module mocking, + * C library mocking, and event testing. + * + * #include + * + * LOGOS_TEST(my_feature_works) { + * auto t = LogosTestContext("my_module"); + * t.mockModule("dep", "method").returns(42); + * MyImpl impl; + * t.init(&impl); + * LOGOS_ASSERT_EQ(impl.callDep(), 42); + * } + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Allow QString and QVariant to be streamed (defined when Qt headers are included) +#ifdef QT_CORE_LIB +#include +#include +inline std::ostream& operator<<(std::ostream& os, const QString& s) { return os << s.toStdString(); } +inline std::ostream& operator<<(std::ostream& os, const QVariant& v) { return os << v.toString().toStdString(); } +#endif + +// --------------------------------------------------------------------------- +// Test failure exception +// --------------------------------------------------------------------------- + +class LogosTestFailure : public std::runtime_error { +public: + explicit LogosTestFailure(const std::string& msg) : std::runtime_error(msg) {} +}; + +// --------------------------------------------------------------------------- +// Assertion macros +// --------------------------------------------------------------------------- + +#define LOGOS_ASSERT(expr) \ + do { \ + if (!(expr)) { \ + std::ostringstream _oss; \ + _oss << "ASSERT failed: " #expr \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_TRUE(expr) LOGOS_ASSERT(expr) +#define LOGOS_ASSERT_FALSE(expr) LOGOS_ASSERT(!(expr)) + +#define LOGOS_ASSERT_EQ(actual, expected) \ + do { \ + auto _logos_a = (actual); \ + auto _logos_e = (expected); \ + if (!(_logos_a == _logos_e)) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_EQ failed: expected [" \ + << _logos_e << "] but got [" << _logos_a << "]" \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_NE(actual, expected) \ + do { \ + auto _logos_a = (actual); \ + auto _logos_e = (expected); \ + if (_logos_a == _logos_e) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_NE failed: both equal [" << _logos_a << "]" \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_GT(actual, threshold) \ + do { \ + auto _logos_a = (actual); \ + auto _logos_t = (threshold); \ + if (!(_logos_a > _logos_t)) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_GT failed: " << _logos_a << " is not > " << _logos_t \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_GE(actual, threshold) \ + do { \ + auto _logos_a = (actual); \ + auto _logos_t = (threshold); \ + if (!(_logos_a >= _logos_t)) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_GE failed: " << _logos_a << " is not >= " << _logos_t \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_LT(actual, threshold) \ + do { \ + auto _logos_a = (actual); \ + auto _logos_t = (threshold); \ + if (!(_logos_a < _logos_t)) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_LT failed: " << _logos_a << " is not < " << _logos_t \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_LE(actual, threshold) \ + do { \ + auto _logos_a = (actual); \ + auto _logos_t = (threshold); \ + if (!(_logos_a <= _logos_t)) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_LE failed: " << _logos_a << " is not <= " << _logos_t \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_CONTAINS(haystack, needle) \ + do { \ + auto _logos_h = (haystack); \ + auto _logos_n = (needle); \ + if (_logos_h.find(_logos_n) == std::string::npos && \ + std::string(_logos_h).find(std::string(_logos_n)) == std::string::npos) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_CONTAINS failed: [" << _logos_h << "] does not contain [" \ + << _logos_n << "]" \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +#define LOGOS_ASSERT_THROWS(expr) \ + do { \ + bool _logos_threw = false; \ + try { expr; } catch (...) { _logos_threw = true; } \ + if (!_logos_threw) { \ + std::ostringstream _oss; \ + _oss << "ASSERT_THROWS failed: no exception thrown" \ + << " (" << __FILE__ << ":" << __LINE__ << ")"; \ + throw LogosTestFailure(_oss.str()); \ + } \ + } while (false) + +// --------------------------------------------------------------------------- +// Test runner — auto-registers tests via static init +// --------------------------------------------------------------------------- + +class LogosTestRunner { +public: + struct TestEntry { + std::string name; + std::function fn; + }; + + static LogosTestRunner& instance() { + static LogosTestRunner runner; + return runner; + } + + bool registerTest(const char* name, std::function fn) { + m_tests.push_back({name, std::move(fn)}); + return true; + } + + // Run all tests. Call from main() — handles arg parsing, QCoreApplication, output. + int run(int argc, char* argv[]); + +private: + LogosTestRunner() = default; + + struct RunConfig { + std::string filter; + bool json = false; + bool noColor = false; + }; + + RunConfig parseArgs(int argc, char* argv[]); + bool matchesFilter(const std::string& name, const std::string& filter); + + // Output helpers + static bool isTTY(); + static std::string colorGreen(bool enabled) { return enabled ? "\033[32m" : ""; } + static std::string colorRed(bool enabled) { return enabled ? "\033[31m" : ""; } + static std::string colorYellow(bool enabled) { return enabled ? "\033[33m" : ""; } + static std::string colorCyan(bool enabled) { return enabled ? "\033[36m" : ""; } + static std::string colorDim(bool enabled) { return enabled ? "\033[2m" : ""; } + static std::string colorBold(bool enabled) { return enabled ? "\033[1m" : ""; } + static std::string colorReset(bool enabled) { return enabled ? "\033[0m" : ""; } + + std::vector m_tests; +}; + +// --------------------------------------------------------------------------- +// LOGOS_TEST macro — register a test function at static init time +// --------------------------------------------------------------------------- + +#define LOGOS_TEST(name) \ + static void _logos_test_fn_##name(); \ + static bool _logos_test_reg_##name = \ + LogosTestRunner::instance().registerTest(#name, _logos_test_fn_##name); \ + static void _logos_test_fn_##name() + +// --------------------------------------------------------------------------- +// LOGOS_TEST_MAIN — generates main() with QCoreApplication + runner +// --------------------------------------------------------------------------- + +#define LOGOS_TEST_MAIN() \ + int main(int argc, char* argv[]) { \ + return LogosTestRunner::instance().run(argc, argv); \ + } + +// --------------------------------------------------------------------------- +// Include sub-headers (available after including logos_test.h) +// --------------------------------------------------------------------------- + +#include "logos_test_context.h" +#include "logos_clib_mock.h" + +#endif // LOGOS_TEST_H diff --git a/include/logos_test_context.h b/include/logos_test_context.h new file mode 100644 index 0000000..c1384a7 --- /dev/null +++ b/include/logos_test_context.h @@ -0,0 +1,146 @@ +#ifndef LOGOS_TEST_CONTEXT_H +#define LOGOS_TEST_CONTEXT_H + +/** + * @file logos_test_context.h + * @brief LogosTestContext — hides all Qt/SDK boilerplate from test code. + * + * Creates LogosAPI in mock mode, manages LogosMockSetup lifecycle, provides + * fluent APIs for module mocking, C library mocking, and event capture. + */ + +#include +#include +#include +#include + +// Forward-declare Qt types (only real classes, not typedefs like QVariantList) +class QVariant; +class LogosAPI; + +#ifdef QT_CORE_LIB +#include +#endif + +// --------------------------------------------------------------------------- +// MockBuilder — fluent builder returned by mockModule() +// --------------------------------------------------------------------------- + +class MockBuilder { +public: + MockBuilder(const std::string& module, const std::string& method); + + MockBuilder& returns(int value); + MockBuilder& returns(bool value); + MockBuilder& returns(double value); + MockBuilder& returns(const char* value); + MockBuilder& returns(const std::string& value); + // For callers that need to pass a QVariant directly (advanced use) + MockBuilder& returnsVariant(const QVariant& value); +#ifdef QT_CORE_LIB + MockBuilder& withArgs(const QVariantList& args); +#endif + +private: + std::string m_module; + std::string m_method; +}; + +// --------------------------------------------------------------------------- +// EventRecord — captured event data +// --------------------------------------------------------------------------- + +struct EventRecord { + std::string name; + std::vector dataStrings; + // Internal: holds the raw QVariantList data (accessible via eventData()) + void* rawData = nullptr; +}; + +// --------------------------------------------------------------------------- +// LogosTestContext +// --------------------------------------------------------------------------- + +class LogosTestContext { +public: + explicit LogosTestContext(const std::string& moduleName); + ~LogosTestContext(); + + // Non-copyable, non-movable + LogosTestContext(const LogosTestContext&) = delete; + LogosTestContext& operator=(const LogosTestContext&) = delete; + + // -- Module mocking (wraps LogosMockSetup) -------------------------------- + + MockBuilder mockModule(const std::string& module, const std::string& method); + bool moduleCalled(const std::string& module, const std::string& method) const; +#ifdef QT_CORE_LIB + bool moduleCalledWith(const std::string& module, const std::string& method, + const QVariantList& args) const; +#endif + int moduleCallCount(const std::string& module, const std::string& method) const; + + // -- C library mocking (delegates to LogosCMockStore) --------------------- + + class CMockBuilder { + public: + explicit CMockBuilder(const std::string& funcName); + CMockBuilder& returns(int value); + CMockBuilder& returns(bool value); + CMockBuilder& returns(double value); + CMockBuilder& returns(const char* value); + CMockBuilder& returns(const std::string& value); + CMockBuilder& returnsPtr(const void* ptr); + CMockBuilder& returnsRaw(const void* data, size_t size); + private: + std::string m_funcName; + }; + + CMockBuilder mockCFunction(const std::string& funcName); + bool cFunctionCalled(const std::string& funcName) const; + int cFunctionCallCount(const std::string& funcName) const; + + // -- Event capture -------------------------------------------------------- + + void captureEvents(); + bool eventEmitted(const std::string& eventName) const; + int eventCount(const std::string& eventName) const; +#ifdef QT_CORE_LIB + const QVariantList& lastEventData(const std::string& eventName) const; +#endif + + // -- Module initialization ------------------------------------------------ + + /** + * Initialize a new-API module (inherits LogosProviderBase). + * Calls impl->init(&logosAPI) internally. + */ + template + auto init(T* impl) -> typename std::enable_if< + std::is_member_function_pointer::value>::type + { + initProvider(static_cast(impl)); + } + + /** + * Fallback for legacy QObject-based modules with initLogos(LogosAPI*). + * Detected via SFINAE when init() is not available. + */ + template + auto initLegacy(T* impl) -> void + { + initLegacyPlugin(static_cast(impl)); + } + + // Access the underlying LogosAPI (advanced use only) + LogosAPI* api() const; + +private: + void initProvider(void* impl); + void initLegacyPlugin(void* impl); + + struct Impl; + std::unique_ptr d; +}; + +#endif // LOGOS_TEST_CONTEXT_H diff --git a/nix/mkLogosModuleTests.nix b/nix/mkLogosModuleTests.nix new file mode 100644 index 0000000..d121016 --- /dev/null +++ b/nix/mkLogosModuleTests.nix @@ -0,0 +1,117 @@ +# mkLogosModuleTests — Nix builder for Logos module unit tests +# +# Builds the test executable from test sources, module sources, and the +# logos-test-framework. Runs the tests as a derivation so they can be +# used as `checks..unit-tests` in a module's flake. +# +# Usage (in a module's flake.nix): +# +# checks.${system}.unit-tests = logos-test-framework.lib.mkLogosModuleTests { +# inherit pkgs; +# src = ./.; +# testDir = ./tests; +# configFile = ./metadata.json; +# logosSdk = logos-cpp-sdk.packages.${system}.default; +# testFramework = logos-test-framework.packages.${system}.default; +# moduleDeps = { test_basic_module = inputs.test_basic_module.packages.${system}.default; }; +# mockCLibs = [ "gowalletsdk" ]; # optional +# preConfigure = ""; # optional +# }; + +{ pkgs +, src +, testDir +, configFile ? null +, logosSdk +, testFramework +, moduleDeps ? {} +, mockCLibs ? [] +, preConfigure ? "" +, extraBuildInputs ? [] +, extraCmakeFlags ? [] +}: + +let + lib = pkgs.lib; + + # Copy dependency include files into generated_code/ + depIncludeSetup = lib.concatMapStringsSep "\n" (name: + let dep = moduleDeps.${name} or null; + in if dep != null then '' + if [ -d "${dep}/include" ]; then + echo "Copying include files from ${name}..." + cp -r "${dep}/include"/* ./generated_code/ 2>/dev/null || true + fi + '' else "" + ) (lib.attrNames moduleDeps); + +in pkgs.stdenv.mkDerivation { + pname = "logos-module-tests"; + version = "0.0.1"; + + inherit src; + + nativeBuildInputs = with pkgs; [ + cmake + pkg-config + qt6.wrapQtAppsHook + ] ++ extraBuildInputs; + + buildInputs = with pkgs; [ + qt6.qtbase + qt6.qtremoteobjects + logosSdk + testFramework + ]; + + cmakeFlags = [ + "-DLOGOS_CPP_SDK_ROOT=${logosSdk}" + "-DLOGOS_TEST_FRAMEWORK_ROOT=${testFramework}" + ] ++ extraCmakeFlags; + + # Build from the test directory + cmakeDir = toString testDir; + + preConfigure = '' + # Set up generated code directory + mkdir -p ./generated_code + + # Copy dependency includes + ${depIncludeSetup} + + # Run logos-cpp-generator if available and metadata exists + ${lib.optionalString (configFile != null) '' + if command -v logos-cpp-generator &>/dev/null && [ -f "${configFile}" ]; then + echo "Running logos-cpp-generator..." + logos-cpp-generator --metadata "${configFile}" --general-only --output-dir ./generated_code || true + fi + ''} + + # Custom preConfigure + ${preConfigure} + ''; + + buildPhase = '' + cmake --build . --parallel $NIX_BUILD_CORES + ''; + + # Run the tests and store the result + installPhase = '' + mkdir -p $out/bin + + # Find and copy test binary + find . -maxdepth 2 -type f -executable -name "*_tests" | head -1 | while read bin; do + cp "$bin" $out/bin/ + done + + # Also try CTest + ctest --output-on-failure --timeout 60 || true + ''; + + # Run tests as a check + doCheck = true; + checkPhase = '' + echo "Running module unit tests..." + ctest --output-on-failure --timeout 60 + ''; +} diff --git a/src/logos_clib_mock.cpp b/src/logos_clib_mock.cpp new file mode 100644 index 0000000..b2d1318 --- /dev/null +++ b/src/logos_clib_mock.cpp @@ -0,0 +1,121 @@ +#include "logos_clib_mock.h" +#include + +// --------------------------------------------------------------------------- +// LogosCMockStore singleton +// --------------------------------------------------------------------------- + +LogosCMockStore& LogosCMockStore::instance() { + static LogosCMockStore store; + return store; +} + +void LogosCMockStore::reset() { + std::lock_guard lock(m_mutex); + m_returns.clear(); + m_calls.clear(); +} + +// --------------------------------------------------------------------------- +// Expectation setup +// --------------------------------------------------------------------------- + +LogosCMockStore::ExpectationBuilder LogosCMockStore::when(const std::string& funcName) { + return ExpectationBuilder(funcName); +} + +LogosCMockStore::ExpectationBuilder::ExpectationBuilder(const std::string& funcName) + : m_funcName(funcName) {} + +LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returns(int value) { + LogosCMockStore::instance().setReturn(m_funcName, &value, sizeof(value)); + return *this; +} + +LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returns(bool value) { + LogosCMockStore::instance().setReturn(m_funcName, &value, sizeof(value)); + return *this; +} + +LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returns(double value) { + LogosCMockStore::instance().setReturn(m_funcName, &value, sizeof(value)); + return *this; +} + +LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returns(const char* value) { + LogosCMockStore::instance().setReturnPtr(m_funcName, static_cast(value)); + return *this; +} + +LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returns(const std::string& value) { + LogosCMockStore::instance().setReturnPtr(m_funcName, static_cast(value.c_str())); + return *this; +} + +LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returnsPtr(const void* ptr) { + LogosCMockStore::instance().setReturnPtr(m_funcName, ptr); + return *this; +} + +LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returnsRaw(const void* data, size_t size) { + LogosCMockStore::instance().setReturn(m_funcName, data, size); + return *this; +} + +// --------------------------------------------------------------------------- +// Call recording +// --------------------------------------------------------------------------- + +void LogosCMockStore::recordCall(const std::string& funcName) { + std::lock_guard lock(m_mutex); + m_calls[funcName]++; +} + +// --------------------------------------------------------------------------- +// Verification +// --------------------------------------------------------------------------- + +bool LogosCMockStore::wasCalled(const std::string& funcName) const { + std::lock_guard lock(m_mutex); + auto it = m_calls.find(funcName); + return it != m_calls.end() && it->second > 0; +} + +int LogosCMockStore::callCount(const std::string& funcName) const { + std::lock_guard lock(m_mutex); + auto it = m_calls.find(funcName); + return (it != m_calls.end()) ? it->second : 0; +} + +// --------------------------------------------------------------------------- +// String return specialization +// --------------------------------------------------------------------------- + +const char* LogosCMockStore::getReturnString(const std::string& funcName) const { + std::lock_guard lock(m_mutex); + auto it = m_returns.find(funcName); + if (it == m_returns.end()) return ""; + + const auto& data = it->second; + if (data.size() < sizeof(uintptr_t)) return ""; + + uintptr_t ptr; + std::memcpy(&ptr, data.data(), sizeof(ptr)); + return reinterpret_cast(ptr); +} + +// --------------------------------------------------------------------------- +// Low-level storage +// --------------------------------------------------------------------------- + +void LogosCMockStore::setReturn(const std::string& funcName, const void* data, size_t size) { + std::lock_guard lock(m_mutex); + auto& vec = m_returns[funcName]; + vec.resize(size); + std::memcpy(vec.data(), data, size); +} + +void LogosCMockStore::setReturnPtr(const std::string& funcName, const void* ptr) { + uintptr_t addr = reinterpret_cast(ptr); + setReturn(funcName, &addr, sizeof(addr)); +} diff --git a/src/logos_test_context.cpp b/src/logos_test_context.cpp new file mode 100644 index 0000000..c5484eb --- /dev/null +++ b/src/logos_test_context.cpp @@ -0,0 +1,263 @@ +#include "logos_test_context.h" +#include "logos_clib_mock.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Internal event record with full QVariantList +// --------------------------------------------------------------------------- + +struct InternalEventRecord { + std::string name; + QVariantList data; +}; + +// --------------------------------------------------------------------------- +// Impl — private implementation hiding all Qt/SDK details +// --------------------------------------------------------------------------- + +struct LogosTestContext::Impl { + std::string moduleName; + LogosMockSetup* mockSetup = nullptr; + LogosAPI* api = nullptr; + bool capturingEvents = false; + std::vector events; + static QVariantList s_emptyVariantList; +}; + +QVariantList LogosTestContext::Impl::s_emptyVariantList; + +// --------------------------------------------------------------------------- +// LogosTestContext +// --------------------------------------------------------------------------- + +LogosTestContext::LogosTestContext(const std::string& moduleName) + : d(std::make_unique()) +{ + d->moduleName = moduleName; + + // Reset C mock store for each test context + LogosCMockStore::instance().reset(); + + // Activate SDK mock mode (clears MockStore + TokenManager) + d->mockSetup = new LogosMockSetup(); + + // Create LogosAPI for this module + d->api = new LogosAPI(QString::fromStdString(moduleName)); +} + +LogosTestContext::~LogosTestContext() { + delete d->api; + delete d->mockSetup; +} + +// -- Module mocking ----------------------------------------------------------- + +MockBuilder LogosTestContext::mockModule(const std::string& module, const std::string& method) { + // Seed a mock token for the target module so LogosAPIClient skips capability_module lookup. + // The actual return value expectation is set by MockBuilder::returns(). + TokenManager::instance().saveToken(QString::fromStdString(module), + "mock-token-" + QString::fromStdString(module)); + return MockBuilder(module, method); +} + +bool LogosTestContext::moduleCalled(const std::string& module, const std::string& method) const { + return d->mockSetup->wasCalled(QString::fromStdString(module), QString::fromStdString(method)); +} + +bool LogosTestContext::moduleCalledWith(const std::string& module, const std::string& method, + const QVariantList& args) const { + return d->mockSetup->wasCalledWith(QString::fromStdString(module), + QString::fromStdString(method), args); +} + +int LogosTestContext::moduleCallCount(const std::string& module, const std::string& method) const { + return d->mockSetup->callCount(QString::fromStdString(module), QString::fromStdString(method)); +} + +// -- C library mocking -------------------------------------------------------- + +LogosTestContext::CMockBuilder LogosTestContext::mockCFunction(const std::string& funcName) { + return CMockBuilder(funcName); +} + +bool LogosTestContext::cFunctionCalled(const std::string& funcName) const { + return LogosCMockStore::instance().wasCalled(funcName); +} + +int LogosTestContext::cFunctionCallCount(const std::string& funcName) const { + return LogosCMockStore::instance().callCount(funcName); +} + +// -- Event capture ------------------------------------------------------------ + +void LogosTestContext::captureEvents() { + d->capturingEvents = true; +} + +bool LogosTestContext::eventEmitted(const std::string& eventName) const { + for (const auto& ev : d->events) { + if (ev.name == eventName) return true; + } + return false; +} + +int LogosTestContext::eventCount(const std::string& eventName) const { + int count = 0; + for (const auto& ev : d->events) { + if (ev.name == eventName) ++count; + } + return count; +} + +const QVariantList& LogosTestContext::lastEventData(const std::string& eventName) const { + for (auto it = d->events.rbegin(); it != d->events.rend(); ++it) { + if (it->name == eventName) return it->data; + } + return Impl::s_emptyVariantList; +} + +// -- Module initialization ---------------------------------------------------- + +void LogosTestContext::initProvider(void* impl) { + auto* provider = static_cast(impl); + + if (d->capturingEvents) { + auto* events = &d->events; + provider->setEventListener([events](const QString& name, const QVariantList& data) { + events->push_back({name.toStdString(), data}); + }); + } + + provider->init(static_cast(d->api)); +} + +void LogosTestContext::initLegacyPlugin(void* impl) { + auto* obj = static_cast(impl); + + int methodIdx = obj->metaObject()->indexOfMethod("initLogos(LogosAPI*)"); + if (methodIdx != -1) { + QMetaObject::invokeMethod(obj, "initLogos", + Qt::DirectConnection, + Q_ARG(LogosAPI*, d->api)); + } else { + qWarning() << "LogosTestContext: legacy plugin has no initLogos(LogosAPI*) slot"; + } +} + +LogosAPI* LogosTestContext::api() const { + return d->api; +} + +// --------------------------------------------------------------------------- +// MockBuilder +// --------------------------------------------------------------------------- + +MockBuilder::MockBuilder(const std::string& module, const std::string& method) + : m_module(module), m_method(method) {} + +MockBuilder& MockBuilder::returns(int value) { + MockStore::instance().when(QString::fromStdString(m_module), + QString::fromStdString(m_method)) + .thenReturn(QVariant(value)); + return *this; +} + +MockBuilder& MockBuilder::returns(bool value) { + MockStore::instance().when(QString::fromStdString(m_module), + QString::fromStdString(m_method)) + .thenReturn(QVariant(value)); + return *this; +} + +MockBuilder& MockBuilder::returns(double value) { + MockStore::instance().when(QString::fromStdString(m_module), + QString::fromStdString(m_method)) + .thenReturn(QVariant(value)); + return *this; +} + +MockBuilder& MockBuilder::returns(const char* value) { + MockStore::instance().when(QString::fromStdString(m_module), + QString::fromStdString(m_method)) + .thenReturn(QVariant(QString::fromUtf8(value))); + return *this; +} + +MockBuilder& MockBuilder::returns(const std::string& value) { + MockStore::instance().when(QString::fromStdString(m_module), + QString::fromStdString(m_method)) + .thenReturn(QVariant(QString::fromStdString(value))); + return *this; +} + +MockBuilder& MockBuilder::returnsVariant(const QVariant& value) { + MockStore::instance().when(QString::fromStdString(m_module), + QString::fromStdString(m_method)) + .thenReturn(value); + return *this; +} + +MockBuilder& MockBuilder::withArgs(const QVariantList& args) { + MockStore::instance().when(QString::fromStdString(m_module), + QString::fromStdString(m_method)) + .withArgs(args); + return *this; +} + +// --------------------------------------------------------------------------- +// CMockBuilder +// --------------------------------------------------------------------------- + +LogosTestContext::CMockBuilder::CMockBuilder(const std::string& funcName) + : m_funcName(funcName) {} + +LogosTestContext::CMockBuilder& LogosTestContext::CMockBuilder::returns(int value) { + LogosCMockStore::instance().setReturn(m_funcName, &value, sizeof(value)); + return *this; +} + +LogosTestContext::CMockBuilder& LogosTestContext::CMockBuilder::returns(bool value) { + LogosCMockStore::instance().setReturn(m_funcName, &value, sizeof(value)); + return *this; +} + +LogosTestContext::CMockBuilder& LogosTestContext::CMockBuilder::returns(double value) { + LogosCMockStore::instance().setReturn(m_funcName, &value, sizeof(value)); + return *this; +} + +LogosTestContext::CMockBuilder& LogosTestContext::CMockBuilder::returns(const char* value) { + LogosCMockStore::instance().setReturnPtr(m_funcName, static_cast(value)); + return *this; +} + +LogosTestContext::CMockBuilder& LogosTestContext::CMockBuilder::returns(const std::string& value) { + // Store a pointer to intern'd string data — caller must keep value alive + // For safety, store the pointer via setReturnPtr + LogosCMockStore::instance().setReturnPtr(m_funcName, static_cast(value.c_str())); + return *this; +} + +LogosTestContext::CMockBuilder& LogosTestContext::CMockBuilder::returnsPtr(const void* ptr) { + LogosCMockStore::instance().setReturnPtr(m_funcName, ptr); + return *this; +} + +LogosTestContext::CMockBuilder& LogosTestContext::CMockBuilder::returnsRaw(const void* data, size_t size) { + LogosCMockStore::instance().setReturn(m_funcName, data, size); + return *this; +} diff --git a/src/logos_test_runner.cpp b/src/logos_test_runner.cpp new file mode 100644 index 0000000..5185c03 --- /dev/null +++ b/src/logos_test_runner.cpp @@ -0,0 +1,225 @@ +#include "logos_test.h" +#include +#include +#include + +#ifdef _WIN32 +#include +#define isatty _isatty +#define fileno _fileno +#else +#include +#endif + +// --------------------------------------------------------------------------- +// TTY detection +// --------------------------------------------------------------------------- + +bool LogosTestRunner::isTTY() { + return isatty(fileno(stdout)) != 0; +} + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +LogosTestRunner::RunConfig LogosTestRunner::parseArgs(int argc, char* argv[]) { + RunConfig cfg; + cfg.noColor = !isTTY(); + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--json") { + cfg.json = true; + } else if (arg == "--no-color") { + cfg.noColor = true; + } else if (arg == "--filter" && i + 1 < argc) { + cfg.filter = argv[++i]; + } else if (arg.rfind("--filter=", 0) == 0) { + cfg.filter = arg.substr(9); + } else if (arg == "--help" || arg == "-h") { + std::cout << "Usage: " << (argc > 0 ? argv[0] : "test") << " [options]\n" + << " --filter Run only tests matching pattern\n" + << " --json Output results as JSON\n" + << " --no-color Disable colored output\n" + << " --help Show this help\n"; + std::exit(0); + } + } + return cfg; +} + +// --------------------------------------------------------------------------- +// Filter matching — simple substring match +// --------------------------------------------------------------------------- + +bool LogosTestRunner::matchesFilter(const std::string& name, const std::string& filter) { + if (filter.empty()) return true; + return name.find(filter) != std::string::npos; +} + +// --------------------------------------------------------------------------- +// JSON string escaping +// --------------------------------------------------------------------------- + +static std::string jsonEscape(const std::string& s) { + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += c; break; + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Main run method +// --------------------------------------------------------------------------- + +int LogosTestRunner::run(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + + RunConfig cfg = parseArgs(argc, argv); + bool color = !cfg.noColor && !cfg.json; + + // Collect tests that match filter + std::vector toRun; + for (const auto& t : m_tests) { + if (matchesFilter(t.name, cfg.filter)) + toRun.push_back(&t); + } + + // Determine suite name from argv[0] + std::string suiteName = "logos_tests"; + if (argc > 0) { + std::string exe = argv[0]; + auto pos = exe.find_last_of("/\\"); + if (pos != std::string::npos) exe = exe.substr(pos + 1); + if (!exe.empty()) suiteName = exe; + } + + int passed = 0; + int failed = 0; + int skipped = static_cast(m_tests.size()) - static_cast(toRun.size()); + + struct TestResult { + std::string name; + bool pass; + double duration_ms; + std::string error; + }; + std::vector results; + + auto totalStart = std::chrono::steady_clock::now(); + + // Header + if (!cfg.json) { + std::cout << "\n" + << colorDim(color) << " ── " << colorReset(color) + << colorBold(color) << suiteName << colorReset(color) + << colorDim(color) << " ──────────────────────────────────" << colorReset(color) + << "\n\n"; + } + + // Run tests + for (const auto* t : toRun) { + auto start = std::chrono::steady_clock::now(); + TestResult r; + r.name = t->name; + r.pass = true; + + if (!cfg.json) { + // No prefix here — we'll print result after + } + + try { + t->fn(); + } catch (const LogosTestFailure& ex) { + r.pass = false; + r.error = ex.what(); + } catch (const std::exception& ex) { + r.pass = false; + r.error = std::string("Unexpected exception: ") + ex.what(); + } catch (...) { + r.pass = false; + r.error = "Unknown exception"; + } + + auto end = std::chrono::steady_clock::now(); + r.duration_ms = std::chrono::duration(end - start).count(); + + if (!cfg.json) { + if (r.pass) { + std::cout << " " << colorGreen(color) << "PASS" << colorReset(color) + << " " << r.name + << colorDim(color) << " " + << std::fixed << std::setprecision(0) + << r.duration_ms << "ms" << colorReset(color) << "\n"; + ++passed; + } else { + std::cout << " " << colorRed(color) << "FAIL" << colorReset(color) + << " " << r.name + << colorDim(color) << " " + << std::fixed << std::setprecision(0) + << r.duration_ms << "ms" << colorReset(color) << "\n" + << " " << colorRed(color) << r.error << colorReset(color) << "\n"; + ++failed; + } + } else { + if (r.pass) ++passed; else ++failed; + } + + results.push_back(std::move(r)); + } + + auto totalEnd = std::chrono::steady_clock::now(); + double totalMs = std::chrono::duration(totalEnd - totalStart).count(); + + // Footer / summary + if (cfg.json) { + std::cout << "{\"suite\":\"" << jsonEscape(suiteName) << "\",\"tests\":["; + for (size_t i = 0; i < results.size(); ++i) { + const auto& r = results[i]; + if (i > 0) std::cout << ","; + std::cout << "{\"name\":\"" << jsonEscape(r.name) << "\"," + << "\"status\":\"" << (r.pass ? "pass" : "fail") << "\"," + << "\"duration_ms\":" << std::fixed << std::setprecision(1) << r.duration_ms; + if (!r.pass) { + std::cout << ",\"error\":\"" << jsonEscape(r.error) << "\""; + } + std::cout << "}"; + } + std::cout << "],\"passed\":" << passed + << ",\"failed\":" << failed + << ",\"skipped\":" << skipped + << ",\"duration_ms\":" << std::fixed << std::setprecision(1) << totalMs + << "}\n"; + } else { + std::cout << "\n" + << colorDim(color) << " ── Results: " << colorReset(color); + if (passed > 0) + std::cout << colorGreen(color) << passed << " passed" << colorReset(color); + if (passed > 0 && failed > 0) + std::cout << ", "; + if (failed > 0) + std::cout << colorRed(color) << failed << " failed" << colorReset(color); + if (passed == 0 && failed == 0) + std::cout << colorYellow(color) << "no tests matched" << colorReset(color); + if (skipped > 0) + std::cout << colorDim(color) << ", " << skipped << " skipped" << colorReset(color); + std::cout << colorDim(color) << " (" << std::fixed << std::setprecision(0) + << totalMs << "ms)" << colorReset(color); + if (toRun.empty()) { + std::cout << colorYellow(color) << " (no tests registered!)" << colorReset(color); + } + std::cout << colorDim(color) << " ──────" << colorReset(color) << "\n\n"; + } + + return failed > 0 ? 1 : 0; +}