test framework for logos modules

This commit is contained in:
Iuri Matias
2026-04-02 16:49:47 -04:00
commit bf3ff3f7cb
20 changed files with 2010 additions and 0 deletions
+216
View File
@@ -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 <pattern>` 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 <logos_test.h>
#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.h>
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 <logos_clib_mock.h>
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 <pattern> Run only tests whose name contains pattern
--json Output results as JSON (for CI/agents)
--no-color Disable colored output
--help Show help
```
+220
View File
@@ -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()
+15
View File
@@ -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
)
+3
View File
@@ -0,0 +1,3 @@
#include <logos_test.h>
LOGOS_TEST_MAIN()
@@ -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 <logos_test.h>
// 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<int>("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"));
}
@@ -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
)
+3
View File
@@ -0,0 +1,3 @@
#include <logos_test.h>
LOGOS_TEST_MAIN()
@@ -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 <logos_clib_mock.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");
}
@@ -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 <logos_test.h>
// 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);
}
+18
View File
@@ -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
)
+3
View File
@@ -0,0 +1,3 @@
#include <logos_test.h>
LOGOS_TEST_MAIN()
+80
View File
@@ -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 <logos_test.h>
// 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);
}
+81
View File
@@ -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"
'';
};
}
);
};
}
+121
View File
@@ -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 <logos_clib_mock.h>
* 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 <string>
#include <unordered_map>
#include <vector>
#include <cstring>
#include <mutex>
#include <cstdint>
// ---------------------------------------------------------------------------
// 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<typename T>
T getReturn(const std::string& funcName) const {
std::lock_guard<std::mutex> 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<std::string, std::vector<uint8_t>> m_returns;
std::unordered_map<std::string, int> 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<type>(funcName)
#define LOGOS_CMOCK_RETURN_STRING(funcName) \
LogosCMockStore::instance().getReturnString(funcName)
#endif // LOGOS_CLIB_MOCK_H
+241
View File
@@ -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.h>
*
* 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 <string>
#include <vector>
#include <functional>
#include <sstream>
#include <stdexcept>
#include <chrono>
#include <iostream>
#include <cstring>
#include <memory>
#include <type_traits>
// Allow QString and QVariant to be streamed (defined when Qt headers are included)
#ifdef QT_CORE_LIB
#include <QString>
#include <QVariant>
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<void()> fn;
};
static LogosTestRunner& instance() {
static LogosTestRunner runner;
return runner;
}
bool registerTest(const char* name, std::function<void()> 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<TestEntry> 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
+146
View File
@@ -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 <string>
#include <memory>
#include <vector>
#include <type_traits>
// Forward-declare Qt types (only real classes, not typedefs like QVariantList)
class QVariant;
class LogosAPI;
#ifdef QT_CORE_LIB
#include <QVariantList>
#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<std::string> 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<typename T>
auto init(T* impl) -> typename std::enable_if<
std::is_member_function_pointer<decltype(&T::init)>::value>::type
{
initProvider(static_cast<void*>(impl));
}
/**
* Fallback for legacy QObject-based modules with initLogos(LogosAPI*).
* Detected via SFINAE when init() is not available.
*/
template<typename T>
auto initLegacy(T* impl) -> void
{
initLegacyPlugin(static_cast<void*>(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<Impl> d;
};
#endif // LOGOS_TEST_CONTEXT_H
+117
View File
@@ -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.<system>.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
'';
}
+121
View File
@@ -0,0 +1,121 @@
#include "logos_clib_mock.h"
#include <cstdint>
// ---------------------------------------------------------------------------
// LogosCMockStore singleton
// ---------------------------------------------------------------------------
LogosCMockStore& LogosCMockStore::instance() {
static LogosCMockStore store;
return store;
}
void LogosCMockStore::reset() {
std::lock_guard<std::mutex> 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<const void*>(value));
return *this;
}
LogosCMockStore::ExpectationBuilder& LogosCMockStore::ExpectationBuilder::returns(const std::string& value) {
LogosCMockStore::instance().setReturnPtr(m_funcName, static_cast<const void*>(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<std::mutex> lock(m_mutex);
m_calls[funcName]++;
}
// ---------------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------------
bool LogosCMockStore::wasCalled(const std::string& funcName) const {
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<const char*>(ptr);
}
// ---------------------------------------------------------------------------
// Low-level storage
// ---------------------------------------------------------------------------
void LogosCMockStore::setReturn(const std::string& funcName, const void* data, size_t size) {
std::lock_guard<std::mutex> 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<uintptr_t>(ptr);
setReturn(funcName, &addr, sizeof(addr));
}
+263
View File
@@ -0,0 +1,263 @@
#include "logos_test_context.h"
#include "logos_clib_mock.h"
#include <logos_api.h>
#include <logos_mode.h>
#include <token_manager.h>
#include <logos_provider_object.h>
#include <implementations/mock/logos_mock.h>
#include <implementations/mock/mock_store.h>
#include <QString>
#include <QVariant>
#include <QVariantList>
#include <QMetaObject>
#include <QDebug>
#include <vector>
#include <string>
// ---------------------------------------------------------------------------
// 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<InternalEventRecord> events;
static QVariantList s_emptyVariantList;
};
QVariantList LogosTestContext::Impl::s_emptyVariantList;
// ---------------------------------------------------------------------------
// LogosTestContext
// ---------------------------------------------------------------------------
LogosTestContext::LogosTestContext(const std::string& moduleName)
: d(std::make_unique<Impl>())
{
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<LogosProviderBase*>(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<void*>(d->api));
}
void LogosTestContext::initLegacyPlugin(void* impl) {
auto* obj = static_cast<QObject*>(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<const void*>(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<const void*>(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;
}
+225
View File
@@ -0,0 +1,225 @@
#include "logos_test.h"
#include <QCoreApplication>
#include <algorithm>
#include <iomanip>
#ifdef _WIN32
#include <io.h>
#define isatty _isatty
#define fileno _fileno
#else
#include <unistd.h>
#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 <pattern> 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<const TestEntry*> 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<int>(m_tests.size()) - static_cast<int>(toRun.size());
struct TestResult {
std::string name;
bool pass;
double duration_ms;
std::string error;
};
std::vector<TestResult> 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<double, std::milli>(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<double, std::milli>(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;
}