Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Thermal Unit Tests

Up to this point, we've been implementing according to the patterns we've established in previous examples, but we haven't yet tried to run anything to verify that it works. Now we will add some unit tests to verify that our mock thermal component behaves as expected.

We will write unit tests for both the MockSensorController and the MockFanController. These tests will cover the basic functionality of each component, ensuring that they respond correctly to temperature readings and cooling requests, and will also verify that the behavior policies are applied correctly.

We do not have a comms system in place yet, so we will not be able to test the full service integration, but we can still verify that the components behave correctly in isolation. We will cover the integration testing in a later section.

Mock Sensor Controller Tests

Let's start with the MockSensorController. Open the file src/mock_sensor_controller.rs and add the following tests at the end of the file:

#![allow(unused)]
fn main() {
// --------------------
#[cfg(test)]
use ec_common::test_helper::join_signals;
#[allow(unused_imports)]
use embassy_executor::Executor;
#[allow(unused_imports)]
use embassy_sync::signal::Signal;
#[allow(unused_imports)]
use static_cell::StaticCell;
#[allow(unused_imports)]
use embedded_services::power::policy::DeviceId;
#[allow(unused_imports)]
use ec_common::mutex::{Mutex, RawMutex};

// Tests that don't need async
#[test]
fn threshold_crossings_and_hysteresis() {
    // Build a controller with a default sensor
    static DEVICE: StaticCell<MockSensorDevice> = StaticCell::new();
    static CONTROLLER: StaticCell<MockSensorController> = StaticCell::new();
    let device = DEVICE.init(MockSensorDevice::new(DeviceId(1)));
    let controller = CONTROLLER.init(MockSensorController::new(device));
    let mut hi_lat = false;
    let mut lo_lat = false;

    // Program thresholds via helpers or direct fields for tests
    let (lo, hi) = (45.0_f32, 50.0_f32);

    // Script: (t, expect)
    use crate::mock_sensor_controller::ThresholdEvent::*;
    let steps = [
        (49.9, None),
        (50.1, OverHigh),
        (49.8, None),                 // still latched above-hi, no duplicate
        (49.3, None),                 // cross below hi - hyst clears latch
        (44.9, UnderLow),
        (45.3, None),                 // cross above low + hyst clears latch
    ];

    for (t, want) in steps {
        let got = controller.eval_thresholds(t, lo, hi, &mut hi_lat, &mut lo_lat);
        assert_eq!(got, want, "t={t}°C");
    }
}

// Tests that need async tasks --
#[test]
fn test_controller() {
    static EXECUTOR: StaticCell<Executor> = StaticCell::new();

    static DEVICE: StaticCell<MockSensorDevice> = StaticCell::new();
    static CONTROLLER: StaticCell<MockSensorController> = StaticCell::new();

    static TSV_DONE: StaticCell<Signal<RawMutex, ()>> = StaticCell::new();

    let executor = EXECUTOR.init(Executor::new());

    let tsv_done = TSV_DONE.init(Signal::new());

    executor.run(|spawner| {        
        let device = DEVICE.init(MockSensorDevice::new(DeviceId(1)));
        let controller = CONTROLLER.init(MockSensorController::new(device));

        let _ = spawner.spawn(test_setting_values(controller, tsv_done));
 
        join_signals(&spawner, [
            tsv_done
        ]);
    });
}

// check initial state, then
// set temperature, thresholds low and high, check sync with the underlying state
#[embassy_executor::task]
async fn test_setting_values(
    mut controller: &'static mut MockSensorController,
    done: &'static Signal<RawMutex, ()>
) 
{
    // verify initial state
    assert_eq!(0.0, controller.sensor.get_temperature());
    assert_eq!(f32::NEG_INFINITY, controller.sensor.get_threshold_low());
    assert_eq!(f32::INFINITY, controller.sensor.get_threshold_high());

    let temp = 12.34;
    let low = -56.78;
    let hi = 67.89;
    controller.sensor.set_temperature(temp);
    let _ = controller.set_temperature_threshold_low(low).await;
    let _ = controller.set_temperature_threshold_high(hi).await;
    let rtemp = controller.temperature().await.unwrap();
    assert_eq!(rtemp, temp);
    assert_eq!(controller.sensor.get_threshold_low(), low);
    assert_eq!(controller.sensor.get_threshold_high(), hi);

    done.signal(());
}
}

This code defines a couple of tests for the MockSensorController. The first test, threshold_crossings_and_hysteresis, checks that the threshold evaluation logic works correctly, including hysteresis behavior. The second test, test_controller, initializes the controller and tests setting temperature and thresholds, verifying that the values are correctly synchronized with the underlying sensor state. Since we are using async tasks, we need to use the embassy_executor crate to run the tests in an async context. We've seen this pattern before, so it should be familiar.

Run these tests using cargo test -p mock_thermal to verify that the MockSensorController behaves as expected.

Mock Fan Controller Tests

Next, let's add tests for the MockFanController. Open the file src/mock_fan_controller.rs and add the following tests at the end of the file:

#![allow(unused)]
fn main() {
// --------------------
#[cfg(test)]
use ec_common::test_helper::join_signals;
#[allow(unused_imports)]
use embassy_executor::Executor;
#[allow(unused_imports)]
use embassy_sync::signal::Signal;
#[allow(unused_imports)]
use static_cell::StaticCell;
#[allow(unused_imports)]
use embedded_services::power::policy::DeviceId;
#[allow(unused_imports)]
use ec_common::mutex::{Mutex, RawMutex};

// Tests that don't need async
#[test]
fn increase_from_zero_triggers_spinup_then_levels() {
    let p = FanPolicy { min_start_rpm: 1000, spinup_hold_ms: 250, ..FanPolicy::default() };
    let r1 = apply_cooling_request(0, CoolingRequest::Increase, &p);
    assert_eq!(r1.new_level, 3);
    assert_eq!(r1.target_rpm_percent, 30);
    assert_eq!(r1.spinup, Some(SpinUp { rpm: 1000, hold_ms: 250 }));

    // Next increase: no spinup, just step
    let r2 = apply_cooling_request(r1.new_level, CoolingRequest::Increase, &p);
    assert_eq!(r2.new_level, 5);
    assert_eq!(r2.spinup, None);
}

#[test]
fn saturates_at_bounds_and_is_idempotent_at_extremes() {
    let p = FanPolicy::default();

    // Clamp at max
    let r = apply_cooling_request(10, CoolingRequest::Increase, &p);
    assert_eq!(r.new_level, 10);
    assert_eq!(r.spinup, None);

    // Clamp at 0
    let r = apply_cooling_request(1, CoolingRequest::Decrease, &p);
    assert_eq!(r.new_level, 0);
    let r = apply_cooling_request(0, CoolingRequest::Decrease, &p);
    assert_eq!(r.new_level, 0);
}

#[test]
fn mapping_to_rpm_is_linear_and_total() {
    assert_eq!(level_to_pwm(0, 10), 0);
    assert_eq!(level_to_pwm(5, 10), 50);
    assert_eq!(level_to_pwm(10, 10), 100);
}

// Tests that need async tasks --
#[test]
fn test_setting_values() {
    static EXECUTOR: StaticCell<Executor> = StaticCell::new();
    static DEVICE: StaticCell<MockFanDevice> = StaticCell::new();
    static CONTROLLER: StaticCell<MockFanController> = StaticCell::new();
    static DONE: StaticCell<Signal<RawMutex, ()>> = StaticCell::new();

    let executor = EXECUTOR.init(Executor::new());
    let done = DONE.init(Signal::new());

    executor.run(|spawner| {       
        let device = DEVICE.init(MockFanDevice::new(DeviceId(1)));
        let controller = CONTROLLER.init(MockFanController::new(device));

        // run these tasks sequentially
        let _ = spawner.spawn(setting_values_test_task(controller, done));
        join_signals(&spawner, [done]);
    });
}
#[test]
fn test_handle_request() {
    static EXECUTOR: StaticCell<Executor> = StaticCell::new();
    static DEVICE: StaticCell<MockFanDevice> = StaticCell::new();
    static CONTROLLER: StaticCell<MockFanController> = StaticCell::new();
    static DONE: StaticCell<Signal<RawMutex, ()>> = StaticCell::new();

    let executor = EXECUTOR.init(Executor::new());
    let done = DONE.init(Signal::new());

    executor.run(|spawner| {       
        let device = DEVICE.init(MockFanDevice::new(DeviceId(1)));
        let controller = CONTROLLER.init(MockFanController::new(device));

        // run these tasks sequentially
        let _ = spawner.spawn(handle_request_test_task(controller, done));
        join_signals(&spawner, [done]);
    });
}

// check initial state, then
// set temperature, thresholds low and high, check sync with the underlying state
#[embassy_executor::task]
async fn setting_values_test_task(
    controller: &'static mut MockFanController,
    done: &'static Signal<RawMutex, ()>
) 
{
    use crate::virtual_fan::{FAN_RPM_MINIMUM, FAN_RPM_MAXIMUM, FAN_RPM_START};
    // verify initial state
    let rpm = controller.rpm().await.unwrap();
    let min = controller.min_rpm();
    let max = controller.max_rpm();
    let min_start = controller.min_start_rpm();
    assert_eq!(rpm, 0);
    assert_eq!(min, FAN_RPM_MINIMUM);
    assert_eq!(max, FAN_RPM_MAXIMUM);
    assert_eq!(min_start, FAN_RPM_START);

    // now set values and verify them
    let _ = controller.set_speed_max().await;
    let v = controller.rpm().await.unwrap();
    assert_eq!(v, FAN_RPM_MAXIMUM);
    let _ = controller.set_speed_percent(50).await;
    let v = controller.rpm().await.unwrap();
    assert_eq!(v, FAN_RPM_MAXIMUM / 2);
    let _ = controller.set_speed_rpm(0).await;
    let v = controller.rpm().await.unwrap();
    assert_eq!(v, 0);

    done.signal(());
}

#[embassy_executor::task]
async fn handle_request_test_task(
    controller: &'static mut MockFanController,
    done: &'static Signal<RawMutex, ()>
) {
    let policy = FanPolicy { min_start_rpm: 1000, spinup_hold_ms: 0, ..Default::default() };

    // Start from 0, request Increase -> expect spinup and final RPM for boost level
    let (res1, rpm1) = controller.handle_request(0, CoolingRequest::Increase, &policy).await.unwrap();
    assert!(res1.spinup.is_some(), "should spin up from 0");
    assert_eq!(res1.new_level, policy.start_boost_level);

    // Final RPM should match the percent mapping for the new level
    let expect1 = percent_to_rpm_max(controller.max_rpm(), level_to_pwm(res1.new_level, policy.max_level));
    assert_eq!(rpm1, expect1);

    // Next increase -> no spinup; just step up by `step`
    let (res2, rpm2) = controller.handle_request(res1.new_level, CoolingRequest::Increase, &policy).await.unwrap();
    assert!(res2.spinup.is_none());
    assert_eq!(res2.new_level, (res1.new_level + policy.step).min(policy.max_level));

    let expect2 = percent_to_rpm_max(controller.max_rpm(), level_to_pwm(res2.new_level, policy.max_level));
    assert_eq!(rpm2, expect2);

    done.signal(());    
}
}

The first test, increase_from_zero_triggers_spinup_then_levels, checks that the fan controller correctly handles an increase request from zero, triggering a spin-up and then setting the fan speed to the appropriate level. The second test, saturates_at_bounds_and_is_idempotent_at_extremes, verifies that the fan controller correctly saturates at the maximum and minimum levels and that repeated requests do not change the state. The third test, mapping_to_rpm_is_linear_and_total, checks that the level-to-PWM mapping is linear and that it correctly maps levels to RPM percentages.

The last two tests, test_setting_values and test_handle_request, are async tasks that test setting the fan speed and handling cooling requests, respectively. They ensure that the fan controller behaves correctly when interacting with the underlying mock fan device.

Run these tests using cargo test -p mock_thermal to verify that the MockFanController behaves as expected.

Conclusion

With these unit tests in place, we have a solid foundation for verifying the behavior of our mock thermal component. These tests cover the basic functionality of both the sensor and fan controllers, ensuring that they respond correctly to temperature readings and cooling requests.

At this point we have created mock representations of an embedded battery and charger, and now a thermal component with a sensor and fan. We have also implemented the necessary traits and controllers to interact with these components. Next, we will look at how to integrate these components into a service and prepare them for use in an embedded system. This will involve creating a service layer that can manage the thermal component and its interactions with the rest of the system, allowing us to test the full functionality of the thermal subsystem in a simulated environment. This will be similar to what we have done previously for the battery and charger components, but with some additional considerations for the thermal component's behavior and interactions. We will also explore how to write integration tests to verify that the thermal component works correctly when integrated with the rest of the system. This will involve simulating the behavior of the thermal component in a more realistic environment, allowing us to test its interactions with other components and services.