Unit Testing

Testing automation code on physical hardware is often slow, expensive, and potentially hazardous. Adapnex separates control algorithms from physical I/O drivers through mapped variables, allowing task logic and control loops to be thoroughly tested in software without physical hardware or hardware driver mocks.

Google Test Framework

Adapnex uses Google Test, the industry-standard C++ testing framework. Google Test provides rich assertion macros such as EXPECT_EQ, ASSERT_TRUE, and EXPECT_NEAR along with structured test fixtures.

Google Test ships bundled directly with the Adapnex SDK. You do not need to download or install external testing libraries separately. Consult the Google Test documentation for a complete reference on available assertions and test macros.

Combined with the built-in Simulation platform, you can test Adapnex applications at three distinct levels:

  1. Direct Task Testing: Testing isolated task algorithms directly in unit tests.

  2. Deterministic Time Simulation: Simulating task groups and time-dependent function blocks with a virtual clock.

  3. Closed-Loop Plant Modeling: Simulating machine dynamics using LambdaTask or dedicated Task models to verify feedback control loops.

Direct Task Testing

Because an Adapnex Task exposes its inputs and outputs as plain member variables, you can instantiate a task directly on the stack inside a standard Google Test (TEST), assign inputs, invoke its Update() method manually, and assert the resulting outputs.

#include "adapnex.h"
#include <gtest/gtest.h>

class ThresholdAlarmTask final : public Task {
public:
    float temperature_in = 0.0f;
    bool alarm_out = false;

    void Update() override {
        alarm_out = temperature_in > 80.0f;
    }
};

TEST(ThresholdAlarmTask, ActivatesAlarmWhenOverLimit) {
    ThresholdAlarmTask task;

    // Normal condition
    task.temperature_in = 72.5f;
    task.Update();
    EXPECT_FALSE(task.alarm_out);

    // Over-temperature condition
    task.temperature_in = 85.0f;
    task.Update();
    EXPECT_TRUE(task.alarm_out);
}

Direct task testing is fast and well-suited for verifying pure mathematical calculations, combinational logic, and discrete state transitions.

Deterministic Time Simulation

Control applications frequently depend on timers (TON, TOF, TP), pulse generators, and rate limits. Testing these with wall-clock time is slow and introduces non-deterministic test flakes due to CPU scheduling.

The Simulation test fixture replaces the real-time operating system scheduler with a virtual clock. When you derive a test suite from Simulation using TEST_F(Simulation, …​), calling Simulate(duration) advances virtual time deterministically:

  • Instant Fast-Forwarding: Hours or days of application execution run in a few milliseconds of CPU time.

  • Zero Jitter: Simulated cycles execute at exact mathematical time steps without scheduling drift or race conditions.

  • Deterministic Results: Tests produce identical results across local developer workstations and CI runners.

#include "adapnex.h"
#include <gtest/gtest.h>

class PulseBeaconTask final : public Task {
public:
    bool enable_in = false;
    bool pulse_out = false;

    void Update() override {
        // Generates a 2-second pulse on a rising edge of enable_in
        pulse_timer(enable_in, 2s);
        pulse_out = pulse_timer.Q;
    }

private:
    TP pulse_timer;
};

TEST_F(Simulation, GeneratesAccuratePulseDuration) {
    const auto group = Application::CreateCyclicTaskGroup({.period = 10ms});
    const auto beacon = group->CreateTask<PulseBeaconTask>();

    // Initial state
    Simulate(100ms);
    EXPECT_FALSE(beacon->pulse_out);

    // Trigger pulse
    beacon->enable_in = true;
    Simulate(10ms);
    EXPECT_TRUE(beacon->pulse_out);

    // Still active after 1 second
    Simulate(1s);
    EXPECT_TRUE(beacon->pulse_out);

    // After 2 seconds total, pulse expires
    Simulate(1s);
    EXPECT_FALSE(beacon->pulse_out);
}

Closed-Loop Plant Simulation

Testing closed-loop controllers (such as hysteresis or PID controllers) requires simulating the physical process (the "plant") responding to controller commands.

You can implement a simplified first-order plant model using a LambdaTask registered in the same task group. In each simulated cycle, the plant task inspects the controller’s outputs, updates the simulated physics, and updates the controller’s input variables:

#include "adapnex.h"
#include <gtest/gtest.h>

class OvenHeaterTask final : public Task {
public:
    float temperature_in = 20.0f;
    bool heater_out = false;

    void Update() override {
        // Hysteresis control: turn heater on below 60C, turn off above 65C
        hysteresis(temperature_in, 60.0f, 65.0f);
        heater_out = hysteresis.Q;
    }

private:
    Hysteresis hysteresis;
};

TEST_F(Simulation, ControlsOvenTemperatureWithinBand) {
    const auto group = Application::CreateCyclicTaskGroup({.period = 100ms});
    const auto oven = group->CreateTask<OvenHeaterTask>();

    // First-order thermal plant simulation
    group->CreateTask<LambdaTask>([&]() {
        if (oven->heater_out) {
            // Heating phase: temperature rises
            oven->temperature_in += 0.05f;
        } else {
            // Ambient cooling phase: temperature drops
            oven->temperature_in -= 0.02f;
        }
    });

    // Simulate 10 minutes of heating from cold ambient
    Simulate(10min);

    // Verify temperature reached the operating band
    EXPECT_GE(oven->temperature_in, 60.0f);
    EXPECT_LE(oven->temperature_in, 66.0f);

    // Simulate an additional 12 hours of steady-state operation
    Simulate(12h);

    // Verify temperature remains regulated within the hysteresis limits
    EXPECT_GE(oven->temperature_in, 59.5f);
    EXPECT_LE(oven->temperature_in, 65.5f);
}

The 12-hour thermal simulation executes in milliseconds, verifying that the hysteresis controller maintains regulation indefinitely without drift or deadlock.

While LambdaTask is well-suited for simple transfer functions or disturbance injection, more complex plant models (such as multi-axis kinematics, fluid networks, or state machines with internal storage) are commonly implemented by creating a dedicated class derived from Task. Dedicated task models encapsulate their own parameters, maintain private state, and can be reused across different test suites.

Declaring Tests in CMake

Register test executables in your CMakeLists.txt using the adapnex_tests() macro provided by the Adapnex toolchain:

cmake_minimum_required(VERSION 3.29)
project(my_controller)

enable_testing()

# Application executable
adapnex_executable(my_controller main.cpp)

# Test suite executable linking Google Test and the Simulation platform
adapnex_tests(my_controller_tests main_test.cpp)

Running Tests

Run test suites using the Adapnex CLI:

$ adapnex test