Move tests to relevant subprojects (#615)

- Moved test projects to the parent project they belong to
- Improved test stability/corectness
- Improved recursive directory deletion by safely ignoring current- and parent-directory entries.
- Update docs
This commit is contained in:
Ken Van Hoeylandt
2026-08-13 23:01:12 +02:00
committed by GitHub
parent d6b1d15e56
commit f943c4dd69
64 changed files with 137 additions and 1539 deletions
+17
View File
@@ -0,0 +1,17 @@
project(TactilityKernelTests)
enable_language(C CXX ASM)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(TactilityKernelTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
target_include_directories(TactilityKernelTests PRIVATE ${DOCTESTINC})
add_test(NAME TactilityKernelTests COMMAND TactilityKernelTests)
target_link_libraries(TactilityKernelTests PUBLIC
TactilityKernel
platform-posix
service-module
)
@@ -0,0 +1,134 @@
#include "doctest.h"
#include <tactility/bundle.h>
#include <cstring>
TEST_CASE("bundle_alloc/bundle_free round-trip") {
Bundle* bundle = bundle_alloc();
CHECK_NE(bundle, nullptr);
bundle_free(bundle);
}
TEST_CASE("bool can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_bool(bundle, "key", true);
CHECK(bundle_has_bool(bundle, "key"));
CHECK_EQ(bundle_get_bool(bundle, "key"), true);
bool out = false;
CHECK(bundle_opt_bool(bundle, "key", &out));
CHECK_EQ(out, true);
bundle_free(bundle);
}
TEST_CASE("int32 can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_int32(bundle, "key", -42);
CHECK(bundle_has_int32(bundle, "key"));
CHECK_EQ(bundle_get_int32(bundle, "key"), -42);
int32_t out = 0;
CHECK(bundle_opt_int32(bundle, "key", &out));
CHECK_EQ(out, -42);
bundle_free(bundle);
}
TEST_CASE("int64 can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_int64(bundle, "key", 123456789012345LL);
CHECK(bundle_has_int64(bundle, "key"));
CHECK_EQ(bundle_get_int64(bundle, "key"), 123456789012345LL);
int64_t out = 0;
CHECK(bundle_opt_int64(bundle, "key", &out));
CHECK_EQ(out, 123456789012345LL);
bundle_free(bundle);
}
TEST_CASE("string can be stored and retrieved") {
Bundle* bundle = bundle_alloc();
bundle_put_string(bundle, "key", "hello world");
CHECK(bundle_has_string(bundle, "key"));
char buffer[32];
CHECK_EQ(bundle_get_string(bundle, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "hello world"), 0);
char tiny[4];
CHECK_EQ(bundle_get_string(bundle, "key", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
char out[32];
CHECK_EQ(bundle_opt_string(bundle, "key", out, sizeof(out)), ERROR_NONE);
CHECK_EQ(std::strcmp(out, "hello world"), 0);
bundle_free(bundle);
}
TEST_CASE("has_*/opt_* reject a key stored with a different type") {
Bundle* bundle = bundle_alloc();
bundle_put_bool(bundle, "key", true);
CHECK_FALSE(bundle_has_int32(bundle, "key"));
CHECK_FALSE(bundle_has_int64(bundle, "key"));
CHECK_FALSE(bundle_has_string(bundle, "key"));
int32_t out_int32 = 0;
CHECK_FALSE(bundle_opt_int32(bundle, "key", &out_int32));
char out_string[8];
CHECK_EQ(bundle_opt_string(bundle, "key", out_string, sizeof(out_string)), ERROR_NOT_FOUND);
bundle_free(bundle);
}
TEST_CASE("opt_string reports ERROR_NOT_FOUND for a missing key") {
Bundle* bundle = bundle_alloc();
char out[8];
CHECK_EQ(bundle_opt_string(bundle, "missing", out, sizeof(out)), ERROR_NOT_FOUND);
bundle_free(bundle);
}
TEST_CASE("bundle_clone makes an independent deep copy") {
Bundle* original = bundle_alloc();
bundle_put_bool(original, "bool", true);
bundle_put_int32(original, "int32", 123);
bundle_put_string(original, "string", "text");
Bundle* clone = bundle_clone(original);
bundle_free(original); // clone must not be affected
CHECK_EQ(bundle_get_bool(clone, "bool"), true);
CHECK_EQ(bundle_get_int32(clone, "int32"), 123);
char buffer[16];
CHECK_EQ(bundle_get_string(clone, "string", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "text"), 0);
// Mutating the clone must not affect a re-clone of the (already-freed) original's data.
bundle_put_int32(clone, "int32", 456);
CHECK_EQ(bundle_get_int32(clone, "int32"), 456);
bundle_free(clone);
}
TEST_CASE("put overwrites a previously stored value, including across types") {
Bundle* bundle = bundle_alloc();
bundle_put_int32(bundle, "key", 1);
bundle_put_string(bundle, "key", "now a string");
CHECK_FALSE(bundle_has_int32(bundle, "key"));
CHECK(bundle_has_string(bundle, "key"));
char buffer[32];
CHECK_EQ(bundle_get_string(bundle, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "now a string"), 0);
bundle_free(bundle);
}
@@ -0,0 +1,163 @@
#include "doctest.h"
#include <atomic>
#include <tactility/concurrent/thread.h>
#include <tactility/delay.h>
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/module.h>
namespace {
Module module = {
.name = "device_get_put_test_module",
.start = nullptr,
.stop = nullptr
};
int start(Device*) { return ERROR_NONE; }
int stop(Device*) { return ERROR_NONE; }
Driver test_driver = {
.name = "device_get_put_test_driver",
.compatible = (const char*[]) { "device_get_put_test", nullptr },
.start_device = start,
.stop_device = stop,
.api = nullptr,
.device_type = nullptr,
.owner = &module,
.internal = nullptr,
};
} // namespace
TEST_CASE("device_get should succeed even when the device is not started") {
Device device = { .name = "get_not_started", .config = nullptr, .parent = nullptr };
CHECK_EQ(driver_construct_add(&test_driver), ERROR_NONE);
CHECK_EQ(device_construct(&device), ERROR_NONE);
device_set_driver(&device, &test_driver);
CHECK_EQ(device_add(&device), ERROR_NONE);
// Ref-counting brackets construct/destruct, not start/stop.
CHECK_EQ(device_get(&device), ERROR_NONE);
device_put(&device);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&test_driver), ERROR_NONE);
}
TEST_CASE("device_get should fail with ERROR_INVALID_STATE once the device has been destructed") {
Device device = { .name = "get_after_destruct", .config = nullptr, .parent = nullptr };
CHECK_EQ(driver_construct_add(&test_driver), ERROR_NONE);
CHECK_EQ(device_construct(&device), ERROR_NONE);
device_set_driver(&device, &test_driver);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(device_get(&device), ERROR_INVALID_STATE);
CHECK_EQ(driver_remove_destruct(&test_driver), ERROR_NONE);
}
TEST_CASE("device_get should succeed once started, and device_put should release it") {
Device device = { .name = "get_started", .config = nullptr, .parent = nullptr };
CHECK_EQ(driver_construct_add(&test_driver), ERROR_NONE);
CHECK_EQ(device_construct(&device), ERROR_NONE);
device_set_driver(&device, &test_driver);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_start(&device), ERROR_NONE);
CHECK_EQ(device_get(&device), ERROR_NONE);
device_put(&device);
CHECK_EQ(device_stop(&device), ERROR_NONE);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&test_driver), ERROR_NONE);
}
TEST_CASE("device_stop should succeed while a reference is held, but device_destruct should fail with ERROR_RESOURCE_BUSY until it is released") {
static Device device = { .name = "get_put_concurrent", .config = nullptr, .parent = nullptr };
static std::atomic<bool> acquired { false };
static std::atomic<bool> release { false };
CHECK_EQ(driver_construct_add(&test_driver), ERROR_NONE);
CHECK_EQ(device_construct(&device), ERROR_NONE);
device_set_driver(&device, &test_driver);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_start(&device), ERROR_NONE);
acquired = false;
release = false;
auto* thread = thread_alloc_full(
"device_get_put_worker",
4096,
[](void*) -> int32_t {
if (device_get(&device) != ERROR_NONE) {
return 1;
}
acquired = true;
while (!release.load()) {
delay_millis(1);
}
device_put(&device);
return 0;
},
nullptr,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
while (!acquired.load()) {
delay_millis(1);
}
// Held by the worker thread right now - device_stop() is independent of ref-counting, so it
// still succeeds; only device_destruct() gates on outstanding refs.
CHECK_EQ(device_stop(&device), ERROR_NONE);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_RESOURCE_BUSY);
release = true;
CHECK_EQ(thread_join(thread, 200, 1), ERROR_NONE);
thread_free(thread);
// Reference released - device_destruct() now succeeds.
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&test_driver), ERROR_NONE);
}
TEST_CASE("device_get_by_name should find and reference an added device regardless of started state, or fail if not found") {
Device device = { .name = "get_by_name_device", .config = nullptr, .parent = nullptr };
CHECK_EQ(driver_construct_add(&test_driver), ERROR_NONE);
CHECK_EQ(device_construct(&device), ERROR_NONE);
device_set_driver(&device, &test_driver);
CHECK_EQ(device_add(&device), ERROR_NONE);
Device* out = nullptr;
CHECK_EQ(device_get_by_name("does_not_exist", &out), ERROR_NOT_FOUND);
// Not started yet - lookup still succeeds, since it only requires the device to be added.
CHECK_EQ(device_get_by_name("get_by_name_device", &out), ERROR_NONE);
CHECK_EQ(out, &device);
device_put(out);
CHECK_EQ(device_start(&device), ERROR_NONE);
CHECK_EQ(device_get_by_name("get_by_name_device", &out), ERROR_NONE);
CHECK_EQ(out, &device);
device_put(out);
CHECK_EQ(device_stop(&device), ERROR_NONE);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&test_driver), ERROR_NONE);
}
@@ -0,0 +1,119 @@
#include "doctest.h"
#include <vector>
#include <tactility/device_listener.h>
// Declared in device_listener.cpp's private header; forward-declared here rather than
// including the private header, matching the pattern used for other internal-only hooks.
extern "C" void device_listener_notify(Device* dev, DeviceEvent event);
static std::vector<std::pair<void*, DeviceEvent>> calls_a;
static std::vector<std::pair<void*, DeviceEvent>> calls_b;
static void listener_a(Device* dev, DeviceEvent event, void* context) {
calls_a.push_back({ context, event });
}
static void listener_b(Device* dev, DeviceEvent event, void* context) {
calls_b.push_back({ context, event });
}
static void reset_calls() {
calls_a.clear();
calls_b.clear();
}
TEST_CASE("device_listener_notify invokes every registered listener with its own context") {
reset_calls();
int context_a = 1;
int context_b = 2;
device_listener_add(listener_a, &context_a);
device_listener_add(listener_b, &context_b);
auto* fake_device = reinterpret_cast<Device*>(0x1000);
device_listener_notify(fake_device, DEVICE_EVENT_STARTED);
CHECK_EQ(calls_a.size(), 1);
CHECK_EQ(calls_a[0].first, &context_a);
CHECK_EQ(calls_a[0].second, DEVICE_EVENT_STARTED);
CHECK_EQ(calls_b.size(), 1);
CHECK_EQ(calls_b[0].first, &context_b);
CHECK_EQ(calls_b[0].second, DEVICE_EVENT_STARTED);
device_listener_remove(listener_a);
device_listener_remove(listener_b);
}
TEST_CASE("device_listener_remove stops further notifications for that callback only") {
reset_calls();
int context_a = 1;
int context_b = 2;
device_listener_add(listener_a, &context_a);
device_listener_add(listener_b, &context_b);
device_listener_remove(listener_a);
auto* fake_device = reinterpret_cast<Device*>(0x1000);
device_listener_notify(fake_device, DEVICE_EVENT_STOPPED);
CHECK_EQ(calls_a.size(), 0);
CHECK_EQ(calls_b.size(), 1);
device_listener_remove(listener_b);
}
TEST_CASE("device_listener_remove on an unregistered callback is a no-op") {
reset_calls();
int context_b = 2;
device_listener_add(listener_b, &context_b);
// listener_a was never added, so removing it must not disturb listener_b.
device_listener_remove(listener_a);
auto* fake_device = reinterpret_cast<Device*>(0x1000);
device_listener_notify(fake_device, DEVICE_EVENT_STARTED);
CHECK_EQ(calls_b.size(), 1);
device_listener_remove(listener_b);
}
static bool reentrant_add_triggered = false;
static void reentrant_listener(Device* dev, DeviceEvent event, void* context) {
calls_a.push_back({ context, event });
if (!reentrant_add_triggered) {
reentrant_add_triggered = true;
// Adding a listener from within a notification must not deadlock: notify() takes a
// snapshot of the listener list under the lock, then invokes callbacks after unlocking.
device_listener_add(listener_b, context);
}
}
TEST_CASE("device_listener_notify is safe when a listener adds another listener during notification") {
reset_calls();
reentrant_add_triggered = false;
int context_a = 1;
device_listener_add(reentrant_listener, &context_a);
auto* fake_device = reinterpret_cast<Device*>(0x1000);
device_listener_notify(fake_device, DEVICE_EVENT_STARTED);
// The listener added during this round of notification was not part of the snapshot,
// so it should not have been invoked yet.
CHECK_EQ(calls_a.size(), 1);
CHECK_EQ(calls_b.size(), 0);
// A second round picks up the newly-added listener.
device_listener_notify(fake_device, DEVICE_EVENT_STOPPED);
CHECK_EQ(calls_a.size(), 2);
CHECK_EQ(calls_b.size(), 1);
device_listener_remove(reentrant_listener);
device_listener_remove(listener_b);
}
@@ -0,0 +1,239 @@
#include "doctest.h"
#include <cstring>
#include <vector>
#include <tactility/device.h>
#include <tactility/module.h>
static Module module = {
.name = "test_module",
.start = nullptr,
.stop = nullptr
};
TEST_CASE("device_construct and device_destruct should set and unset the constructed state") {
Device device = { 0 };
error_t error = device_construct(&device);
CHECK_EQ(error, ERROR_NONE);
CHECK_EQ(device_is_constructed(&device), true);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(device_is_constructed(&device), false);
}
TEST_CASE("device_construct should be reusable after device_destruct on the same Device") {
Device device = { 0 };
CHECK_EQ(device_construct(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
// Reconstruct on the same static Device: fresh allocation, behaves like a new device.
CHECK_EQ(device_construct(&device), ERROR_NONE);
CHECK_EQ(device_is_constructed(&device), true);
CHECK_EQ(device_is_added(&device), false);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
}
TEST_CASE("device_add should add the device to the list of all devices") {
Device device = {
.name = "device",
.config = nullptr,
.parent = nullptr,
.internal = nullptr
};
CHECK_EQ(device_construct(&device), ERROR_NONE);
CHECK_EQ(device_add(&device), ERROR_NONE);
// Gather all devices
std::vector<Device*> devices;
device_for_each(&devices, [](auto* device, auto* context) {
auto* devices_ptr = static_cast<std::vector<Device*>*>(context);
devices_ptr->push_back(device);
return true;
});
CHECK_EQ(devices.size(), 1);
CHECK_EQ(devices[0], &device);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
}
TEST_CASE("device_add should add the device to its parent") {
Device parent = {
.name = "parent",
.config = nullptr,
.parent = nullptr,
.internal = nullptr
};
Device child = {
.name = "child",
.config = nullptr,
.parent = &parent,
.internal = nullptr
};
CHECK_EQ(device_construct(&parent), ERROR_NONE);
CHECK_EQ(device_add(&parent), ERROR_NONE);
CHECK_EQ(device_construct(&child), ERROR_NONE);
CHECK_EQ(device_add(&child), ERROR_NONE);
// Gather all child devices
std::vector<Device*> children;
device_for_each_child(&parent, &children, [](auto* child_device, auto* context) {
auto* children_ptr = (std::vector<Device*>*)context;
children_ptr->push_back(child_device);
return true;
});
CHECK_EQ(children.size(), 1);
CHECK_EQ(children[0], &child);
CHECK_EQ(device_remove(&child), ERROR_NONE);
CHECK_EQ(device_destruct(&child), ERROR_NONE);
CHECK_EQ(device_remove(&parent), ERROR_NONE);
CHECK_EQ(device_destruct(&parent), ERROR_NONE);
}
TEST_CASE("device_add should set the state to 'added'") {
Device device = {
.name = "device",
.config = nullptr,
.parent = nullptr,
.internal = nullptr
};
CHECK_EQ(device_construct(&device), ERROR_NONE);
CHECK_EQ(device_is_added(&device), false);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_is_added(&device), true);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
}
TEST_CASE("device_remove should remove it from the list of all devices") {
Device device = {
.name = "device",
.config = nullptr,
.parent = nullptr,
.internal = nullptr
};
CHECK_EQ(device_construct(&device), ERROR_NONE);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_remove(&device), ERROR_NONE);
// Gather all devices
std::vector<Device*> devices;
device_for_each(&devices, [](auto* device, auto* context) {
auto* devices_ptr = (std::vector<Device*>*)context;
devices_ptr->push_back(device);
return true;
});
CHECK_EQ(devices.size(), 0);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
}
TEST_CASE("device_remove should remove the device from its parent") {
Device parent = {
.name = "parent",
.config = nullptr,
.parent = nullptr,
.internal = nullptr
};
Device child = {
.name = "child",
.config = nullptr,
.parent = &parent,
.internal = nullptr
};
CHECK_EQ(device_construct(&parent), ERROR_NONE);
CHECK_EQ(device_add(&parent), ERROR_NONE);
CHECK_EQ(device_construct(&child), ERROR_NONE);
CHECK_EQ(device_add(&child), ERROR_NONE);
CHECK_EQ(device_remove(&child), ERROR_NONE);
// Gather all child devices
std::vector<Device*> children;
device_for_each_child(&parent, &children, [](auto* child_device, auto* context) {
auto* children_ptr = (std::vector<Device*>*)context;
children_ptr->push_back(child_device);
return true;
});
CHECK_EQ(children.size(), 0);
CHECK_EQ(device_destruct(&child), ERROR_NONE);
CHECK_EQ(device_remove(&parent), ERROR_NONE);
CHECK_EQ(device_destruct(&parent), ERROR_NONE);
}
TEST_CASE("device_remove should clear the state 'added'") {
Device device = {
.name = "device",
.config = nullptr,
.parent = nullptr,
.internal = nullptr
};
CHECK_EQ(device_construct(&device), ERROR_NONE);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_is_added(&device), true);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_is_added(&device), false);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
}
TEST_CASE("device_is_ready should return true only when it is started") {
const char* compatible[] = { "test_compatible", nullptr };
Driver driver = {
.name = "test_driver",
.compatible = compatible,
.start_device = nullptr,
.stop_device = nullptr,
.api = nullptr,
.device_type = nullptr,
.owner = &module,
.internal = nullptr
};
Device device = { 0 };
CHECK_EQ(driver_construct_add(&driver), ERROR_NONE);
CHECK_EQ(device_construct(&device), ERROR_NONE);
CHECK_EQ(device_is_ready(&device), false);
device_set_driver(&device, &driver);
CHECK_EQ(device_is_ready(&device), false);
CHECK_EQ(device_add(&device), ERROR_NONE);
CHECK_EQ(device_is_ready(&device), false);
CHECK_EQ(device_start(&device), ERROR_NONE);
CHECK_EQ(device_is_ready(&device), true);
CHECK_EQ(device_stop(&device), ERROR_NONE);
CHECK_EQ(device_is_ready(&device), false);
CHECK_EQ(device_remove(&device), ERROR_NONE);
CHECK_EQ(device_is_ready(&device), false);
CHECK_EQ(device_destruct(&device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&driver), ERROR_NONE);
}
@@ -0,0 +1,25 @@
#include "doctest.h"
#include <tactility/freertos/task.h>
#include <tactility/concurrent/dispatcher.h>
TEST_CASE("dispatcher test") {
DispatcherHandle_t dispatcher = dispatcher_alloc();
CHECK_NE(dispatcher, nullptr);
int count = 0;
auto error = dispatcher_dispatch(dispatcher, &count, [](void* context) {
int* count_ptr = static_cast<int*>(context);
(*count_ptr)++;
});
CHECK_EQ(error, ERROR_NONE);
vTaskDelay(1);
CHECK_EQ(count, 0);
CHECK_EQ(dispatcher_consume(dispatcher), ERROR_NONE);
CHECK_EQ(count, 1);
dispatcher_free(dispatcher);
}
@@ -0,0 +1,72 @@
#include "doctest.h"
#include <tactility/driver.h>
#include <tactility/device.h>
#include <tactility/module.h>
static Module module = {
.name = "test_module",
.start = nullptr,
.stop = nullptr
};
struct IntegrationDriverConfig {
int startResult;
int stopResult;
};
static int startCalled = 0;
static int stopCalled = 0;
#define integration_data(device) static_cast<IntegrationDriverData*>(device_get_driver_data(device))
#define integration_config(device) static_cast<const IntegrationDriverConfig*>(device->config)
static int start(Device* device) {
startCalled++;
return integration_config(device)->startResult;
}
static int stop(Device* device) {
stopCalled++;
return integration_config(device)->stopResult;
}
static Driver integration_driver = {
.name = "integration_test_driver",
.compatible = (const char*[]) { "integration", nullptr },
.start_device = start,
.stop_device = stop,
.api = nullptr,
.device_type = nullptr,
.owner = &module,
.internal = nullptr,
};
TEST_CASE("driver with with start success and stop success should start and stop a device") {
startCalled = 0;
stopCalled = 0;
static const IntegrationDriverConfig config {
.startResult = 0,
.stopResult = 0
};
static Device integration_device {
.name = "integration_device",
.config = &config,
.parent = nullptr,
};
CHECK_EQ(driver_construct_add(&integration_driver), ERROR_NONE);
CHECK_EQ(device_construct(&integration_device), ERROR_NONE);
device_add(&integration_device);
CHECK_EQ(startCalled, 0);
CHECK_EQ(driver_bind(&integration_driver, &integration_device), ERROR_NONE);
CHECK_EQ(startCalled, 1);
CHECK_EQ(stopCalled, 0);
CHECK_EQ(driver_unbind(&integration_driver, &integration_device), ERROR_NONE);
CHECK_EQ(stopCalled, 1);
CHECK_EQ(device_remove(&integration_device), ERROR_NONE);
CHECK_EQ(device_destruct(&integration_device), ERROR_NONE);
CHECK_EQ(driver_remove_destruct(&integration_driver), ERROR_NONE);
}
@@ -0,0 +1,77 @@
#include "doctest.h"
#include <tactility/driver.h>
#include <tactility/module.h>
static Module module = {
.name = "test_module",
.start = nullptr,
.stop = nullptr
};
TEST_CASE("driver_construct and driver_destruct should set and unset the correct fields") {
Driver driver = { 0 };
driver.owner = &module;
CHECK_EQ(driver_construct(&driver), ERROR_NONE);
CHECK_EQ(driver_add(&driver), ERROR_NONE);
CHECK_NE(driver.internal, nullptr);
CHECK_EQ(driver_remove(&driver), ERROR_NONE);
CHECK_EQ(driver_destruct(&driver), ERROR_NONE);
CHECK_EQ(driver.internal, nullptr);
}
TEST_CASE("a driver without a module should not be destructible") {
Driver driver = { 0 };
CHECK_EQ(driver_construct(&driver), ERROR_NONE);
CHECK_EQ(driver_destruct(&driver), ERROR_NOT_ALLOWED);
driver.owner = &module;
CHECK_EQ(driver_destruct(&driver), ERROR_NONE);
}
TEST_CASE("driver_is_compatible should return true if a compatible value is found") {
const char* compatible[] = { "test_compatible", nullptr };
Driver driver = {
.name = "test_driver",
.compatible = compatible,
.start_device = nullptr,
.stop_device = nullptr,
.api = nullptr,
.device_type = nullptr,
.owner = &module,
.internal = nullptr
};
CHECK_EQ(driver_is_compatible(&driver, "test_compatible"), true);
CHECK_EQ(driver_is_compatible(&driver, "nope"), false);
CHECK_EQ(driver_is_compatible(&driver, nullptr), false);
}
TEST_CASE("driver_find should only find a compatible driver when the driver was constructed") {
const char* compatible[] = { "test_compatible", nullptr };
Driver driver = {
.name = "test_driver",
.compatible = compatible,
.start_device = nullptr,
.stop_device = nullptr,
.api = nullptr,
.device_type = nullptr,
.owner = &module,
.internal = nullptr
};
Driver* found_driver = driver_find_compatible("test_compatible");
CHECK_EQ(found_driver, nullptr);
CHECK_EQ(driver_construct(&driver), ERROR_NONE);
CHECK_EQ(driver_add(&driver), ERROR_NONE);
found_driver = driver_find_compatible("test_compatible");
CHECK_EQ(found_driver, &driver);
CHECK_EQ(driver_remove(&driver), ERROR_NONE);
CHECK_EQ(driver_destruct(&driver), ERROR_NONE);
found_driver = driver_find_compatible("test_compatible");
CHECK_EQ(found_driver, nullptr);
}
@@ -0,0 +1,122 @@
#include "doctest.h"
#include <tactility/filesystem/file_mutex.h>
namespace {
int lock_calls = 0;
int unlock_calls = 0;
int try_lock_calls = 0;
bool try_lock_result = true;
uint32_t try_lock_timeout_seen = 0;
void mock_lock() { lock_calls++; }
void mock_unlock() { unlock_calls++; }
bool mock_try_lock(uint32_t timeout) {
try_lock_calls++;
try_lock_timeout_seen = timeout;
return try_lock_result;
}
int lock_a_calls = 0;
int lock_b_calls = 0;
void mock_lock_a() { lock_a_calls++; }
void mock_lock_b() { lock_b_calls++; }
void reset_mocks() {
lock_calls = 0;
unlock_calls = 0;
try_lock_calls = 0;
try_lock_result = true;
try_lock_timeout_seen = 0;
lock_a_calls = 0;
lock_b_calls = 0;
}
} // namespace
TEST_CASE("file_mutex_get with zero registrations returns a no-op mutex") {
FileMutex mutex;
file_mutex_get(&mutex, "/nowhere/file.txt");
CHECK_EQ(mutex.lock, nullptr);
CHECK_EQ(mutex.try_lock, nullptr);
CHECK_EQ(mutex.unlock, nullptr);
// Calling through a no-op mutex must be safe, and try_lock must report success.
file_mutex_lock(&mutex);
CHECK_EQ(file_mutex_try_lock(&mutex, 123), true);
file_mutex_unlock(&mutex);
}
TEST_CASE("file_mutex_register/get with a single registration") {
reset_mocks();
FileMutex registered = { .lock = mock_lock, .try_lock = mock_try_lock, .unlock = mock_unlock };
file_mutex_register(&registered, "/mock1");
FileMutex mutex;
// Exact mount path match.
file_mutex_get(&mutex, "/mock1");
CHECK_EQ(mutex.lock, mock_lock);
CHECK_EQ(mutex.try_lock, mock_try_lock);
CHECK_EQ(mutex.unlock, mock_unlock);
// Descendant path match.
file_mutex_get(&mutex, "/mock1/nested/file.txt");
CHECK_EQ(mutex.lock, mock_lock);
// Unrelated path falls back to no-op.
FileMutex unrelated;
file_mutex_get(&unrelated, "/other/file.txt");
CHECK_EQ(unrelated.lock, nullptr);
// Prefix-but-not-descendant path (e.g. "/mock1x") must not match "/mock1".
FileMutex prefix_only;
file_mutex_get(&prefix_only, "/mock1x/file.txt");
CHECK_EQ(prefix_only.lock, nullptr);
// Exercise the resolved callbacks.
file_mutex_get(&mutex, "/mock1");
file_mutex_lock(&mutex);
CHECK_EQ(lock_calls, 1);
CHECK_EQ(file_mutex_try_lock(&mutex, 42), true);
CHECK_EQ(try_lock_calls, 1);
CHECK_EQ(try_lock_timeout_seen, 42);
file_mutex_unlock(&mutex);
CHECK_EQ(unlock_calls, 1);
// Re-registering the same path is a no-op: original callbacks remain in place.
FileMutex replacement = { .lock = nullptr, .try_lock = nullptr, .unlock = nullptr };
file_mutex_register(&replacement, "/mock1");
file_mutex_get(&mutex, "/mock1");
CHECK_EQ(mutex.lock, mock_lock);
}
TEST_CASE("file_mutex_register/get with two registrations resolves to the matching path") {
reset_mocks();
FileMutex mutex_a = { .lock = mock_lock_a, .try_lock = nullptr, .unlock = nullptr };
FileMutex mutex_b = { .lock = mock_lock_b, .try_lock = nullptr, .unlock = nullptr };
file_mutex_register(&mutex_a, "/mock2a");
file_mutex_register(&mutex_b, "/mock2b");
FileMutex resolved;
file_mutex_get(&resolved, "/mock2a/file.txt");
CHECK_EQ(resolved.lock, mock_lock_a);
file_mutex_get(&resolved, "/mock2b/file.txt");
CHECK_EQ(resolved.lock, mock_lock_b);
// Path matching neither registration falls back to no-op.
file_mutex_get(&resolved, "/mock2c/file.txt");
CHECK_EQ(resolved.lock, nullptr);
// Registration order matters: the first matching entry wins, not the longest
// prefix. A mount nested under an earlier one is shadowed by it.
FileMutex mutex_nested = { .lock = nullptr, .try_lock = nullptr, .unlock = nullptr };
file_mutex_register(&mutex_nested, "/mock2a/nested");
file_mutex_get(&resolved, "/mock2a/nested/file.txt");
CHECK_EQ(resolved.lock, mock_lock_a); // still /mock2a, registered first
}
@@ -0,0 +1,182 @@
#include "doctest.h"
#include <algorithm>
#include <cstring>
#include <vector>
#include <tactility/filesystem/file_system.h>
static int mount_called = 0;
static int unmount_called = 0;
static bool mounted_state = false;
static error_t mount_result = ERROR_NONE;
static error_t unmount_result = ERROR_NONE;
static error_t test_mount(void*) {
mount_called++;
mounted_state = true;
return mount_result;
}
static error_t test_unmount(void*) {
unmount_called++;
mounted_state = false;
return unmount_result;
}
static bool test_is_mounted(void*) {
return mounted_state;
}
static error_t test_get_path(void* data, char* out_path, size_t out_path_size) {
const char* path = static_cast<const char*>(data);
if (std::strlen(path) + 1 > out_path_size) {
return ERROR_BUFFER_OVERFLOW;
}
std::strcpy(out_path, path);
return ERROR_NONE;
}
static const FileSystemApi test_api = {
.mount = test_mount,
.unmount = test_unmount,
.is_mounted = test_is_mounted,
.get_path = test_get_path
};
static void reset_counters() {
mount_called = 0;
unmount_called = 0;
mounted_state = false;
mount_result = ERROR_NONE;
unmount_result = ERROR_NONE;
}
TEST_CASE("file_system_mount/unmount delegate to the api and reflect is_mounted state") {
reset_counters();
char path_data[] = "some/path";
FileSystem* fs = file_system_add(&test_api, path_data);
CHECK_EQ(file_system_is_mounted(fs), false);
CHECK_EQ(file_system_mount(fs), ERROR_NONE);
CHECK_EQ(mount_called, 1);
CHECK_EQ(file_system_is_mounted(fs), true);
CHECK_EQ(file_system_unmount(fs), ERROR_NONE);
CHECK_EQ(unmount_called, 1);
CHECK_EQ(file_system_is_mounted(fs), false);
file_system_remove(fs);
}
TEST_CASE("file_system_mount propagates api failure without changing state on its own") {
reset_counters();
mount_result = ERROR_RESOURCE;
char path_data[] = "some/path";
FileSystem* fs = file_system_add(&test_api, path_data);
CHECK_EQ(file_system_mount(fs), ERROR_RESOURCE);
// The fake api still flips mounted_state; file_system itself has no independent state,
// it always defers to the api's is_mounted().
CHECK_EQ(file_system_is_mounted(fs), true);
mounted_state = false;
file_system_remove(fs);
}
TEST_CASE("file_system_get_path forwards to the api with the caller's buffer size") {
reset_counters();
char path_data[] = "mount/point";
FileSystem* fs = file_system_add(&test_api, path_data);
char small_buffer[4];
CHECK_EQ(file_system_get_path(fs, small_buffer, sizeof(small_buffer)), ERROR_BUFFER_OVERFLOW);
char big_buffer[32];
CHECK_EQ(file_system_get_path(fs, big_buffer, sizeof(big_buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(big_buffer, "mount/point"), 0);
file_system_remove(fs);
}
TEST_CASE("file_system_set_owner/get_owner round-trip and default to null") {
reset_counters();
char path_data[] = "some/path";
FileSystem* fs = file_system_add(&test_api, path_data);
CHECK_EQ(file_system_get_owner(fs), nullptr);
auto* fake_owner = reinterpret_cast<Device*>(0x1234);
file_system_set_owner(fs, fake_owner);
CHECK_EQ(file_system_get_owner(fs), fake_owner);
file_system_set_owner(fs, nullptr);
CHECK_EQ(file_system_get_owner(fs), nullptr);
file_system_remove(fs);
}
TEST_CASE("file_system_for_each visits every registered file system") {
reset_counters();
char path_a[] = "a";
char path_b[] = "b";
char path_c[] = "c";
FileSystem* fs_a = file_system_add(&test_api, path_a);
FileSystem* fs_b = file_system_add(&test_api, path_b);
FileSystem* fs_c = file_system_add(&test_api, path_c);
std::vector<FileSystem*> visited;
file_system_for_each(&visited, [](FileSystem* fs, void* context) {
static_cast<std::vector<FileSystem*>*>(context)->push_back(fs);
return true;
});
CHECK_EQ(visited.size(), 3);
CHECK(std::find(visited.begin(), visited.end(), fs_a) != visited.end());
CHECK(std::find(visited.begin(), visited.end(), fs_b) != visited.end());
CHECK(std::find(visited.begin(), visited.end(), fs_c) != visited.end());
file_system_remove(fs_a);
file_system_remove(fs_b);
file_system_remove(fs_c);
}
TEST_CASE("file_system_for_each stops early when the callback returns false") {
reset_counters();
char path_a[] = "a";
char path_b[] = "b";
FileSystem* fs_a = file_system_add(&test_api, path_a);
FileSystem* fs_b = file_system_add(&test_api, path_b);
int visit_count = 0;
file_system_for_each(&visit_count, [](FileSystem*, void* context) {
(*static_cast<int*>(context))++;
return false; // stop after the first entry
});
CHECK_EQ(visit_count, 1);
file_system_remove(fs_a);
file_system_remove(fs_b);
}
TEST_CASE("file_system_remove drops the file system from subsequent iteration") {
reset_counters();
char path_a[] = "a";
char path_b[] = "b";
FileSystem* fs_a = file_system_add(&test_api, path_a);
FileSystem* fs_b = file_system_add(&test_api, path_b);
file_system_remove(fs_a);
std::vector<FileSystem*> visited;
file_system_for_each(&visited, [](FileSystem* fs, void* context) {
static_cast<std::vector<FileSystem*>*>(context)->push_back(fs);
return true;
});
CHECK_EQ(visited.size(), 1);
CHECK_EQ(visited[0], fs_b);
file_system_remove(fs_b);
}
+63
View File
@@ -0,0 +1,63 @@
#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
#include <tactility/check.h>
#include <tactility/dts.h>
#include <tactility/freertos/task.h>
#include <tactility/kernel_init.h>
typedef struct {
int argc;
char** argv;
int result;
} TestTaskData;
// From the relevant platform
extern "C" struct Module platform_posix_module;
void test_task(void* parameter) {
auto* data = (TestTaskData*)parameter;
doctest::Context context;
context.applyCommandLine(data->argc, data->argv);
// overrides
context.setOption("no-breaks", true); // don't break in the debugger when assertions fail
Module* dts_modules[] = { &platform_posix_module, nullptr };
DtsDevice dts_devices[] = { DTS_DEVICE_TERMINATOR };
check(kernel_init(dts_modules, dts_devices) == ERROR_NONE);
data->result = context.run();
vTaskEndScheduler();
vTaskDelete(nullptr);
}
int main(int argc, char** argv) {
TestTaskData data = {
.argc = argc,
.argv = argv,
.result = 0
};
BaseType_t task_result = xTaskCreate(
test_task,
"test_task",
8192,
&data,
1,
nullptr
);
if (task_result != pdPASS) {
return 1;
}
vTaskStartScheduler();
return data.result;
}
@@ -0,0 +1,83 @@
#include "doctest.h"
#include <tactility/memory.h>
#include <cstdint>
#include <cstring>
TEST_CASE("MEMORY_POLICY_DEFAULT should have no requirements") {
CHECK_EQ(MEMORY_POLICY_DEFAULT.required, 0);
CHECK_EQ(MEMORY_POLICY_DEFAULT.desired, 0);
CHECK_EQ(MEMORY_POLICY_DEFAULT.alignment, 0);
}
TEST_CASE("memory_alloc should return usable memory") {
void* ptr = memory_alloc(64);
REQUIRE_NE(ptr, nullptr);
memset(ptr, 0xAB, 64);
CHECK_EQ(static_cast<uint8_t*>(ptr)[0], 0xAB);
CHECK_EQ(static_cast<uint8_t*>(ptr)[63], 0xAB);
memory_free(ptr);
}
TEST_CASE("memory_calloc should zero-initialize memory") {
auto* ptr = static_cast<uint8_t*>(memory_calloc(16, sizeof(uint8_t)));
REQUIRE_NE(ptr, nullptr);
for (size_t i = 0; i < 16; i++) {
CHECK_EQ(ptr[i], 0);
}
memory_free(ptr);
}
TEST_CASE("memory_realloc should preserve contents when growing") {
auto* ptr = static_cast<uint8_t*>(memory_alloc(8));
REQUIRE_NE(ptr, nullptr);
for (uint8_t i = 0; i < 8; i++) {
ptr[i] = i;
}
auto* grown = static_cast<uint8_t*>(memory_realloc(ptr, 32));
REQUIRE_NE(grown, nullptr);
for (uint8_t i = 0; i < 8; i++) {
CHECK_EQ(grown[i], i);
}
memory_free(grown);
}
TEST_CASE("memory_realloc with a NULL pointer should behave like an allocation") {
void* ptr = memory_realloc(nullptr, 32);
REQUIRE_NE(ptr, nullptr);
memset(ptr, 0, 32);
memory_free(ptr);
}
TEST_CASE("memory_free with a NULL pointer should be a no-op") {
memory_free(nullptr);
}
TEST_CASE("memory_alloc_with_policy should honor a power-of-2 alignment") {
MemoryPolicy policy = MEMORY_POLICY_DEFAULT;
policy.alignment = 64;
void* ptr = memory_alloc_with_policy(128, &policy);
REQUIRE_NE(ptr, nullptr);
CHECK_EQ(reinterpret_cast<uintptr_t>(ptr) % 64, 0);
memory_free(ptr);
}
TEST_CASE("memory_calloc_with_policy should honor alignment and zero-initialize") {
MemoryPolicy policy = MEMORY_POLICY_DEFAULT;
policy.alignment = 32;
auto* ptr = static_cast<uint8_t*>(memory_calloc_with_policy(8, sizeof(uint32_t), &policy));
REQUIRE_NE(ptr, nullptr);
CHECK_EQ(reinterpret_cast<uintptr_t>(ptr) % 32, 0);
for (size_t i = 0; i < 8 * sizeof(uint32_t); i++) {
CHECK_EQ(ptr[i], 0);
}
memory_free(ptr);
}
TEST_CASE("memory_print_stats should not crash") {
memory_print_stats();
}
@@ -0,0 +1,236 @@
#include "doctest.h"
#include <tactility/module.h>
static void symbol_test_function() { /* NO-OP */ }
static error_t test_start_result = ERROR_NONE;
static bool start_called = false;
static struct Module* start_add_order_check_module = nullptr;
static error_t test_start() {
start_called = true;
if (start_add_order_check_module != nullptr) {
// If the module was already added to the ledger before start() runs,
// a duplicate module_add() must report that it already exists.
CHECK_EQ(module_add(start_add_order_check_module), ERROR_INVALID_STATE);
}
return test_start_result;
}
static error_t test_stop_result = ERROR_NONE;
static bool stop_called = false;
static error_t test_stop() {
stop_called = true;
return test_stop_result;
}
TEST_CASE("Module construction and destruction") {
struct Module module = {
.name = "test",
.start = test_start,
.stop = test_stop,
.symbols = nullptr,
.internal = nullptr
};
// Test successful construction
CHECK_EQ(module_construct(&module), ERROR_NONE);
CHECK_EQ(module_is_started(&module), false);
// Test successful destruction
CHECK_EQ(module_destruct(&module), ERROR_NONE);
}
TEST_CASE("Module registration") {
struct Module module = {
.name = "test",
.start = test_start,
.stop = test_stop,
.symbols = nullptr,
.internal = nullptr
};
// module_add should succeed
CHECK_EQ(module_add(&module), ERROR_NONE);
// module_remove should succeed
CHECK_EQ(module_remove(&module), ERROR_NONE);
}
TEST_CASE("Module lifecycle") {
start_called = false;
stop_called = false;
test_start_result = ERROR_NONE;
test_stop_result = ERROR_NONE;
struct Module module = {
.name = "test",
.start = test_start,
.stop = test_stop,
.symbols = nullptr,
.internal = nullptr
};
CHECK_EQ(module_construct(&module), ERROR_NONE);
// 1. Successful start (no parent required anymore)
CHECK_EQ(module_start(&module), ERROR_NONE);
CHECK_EQ(module_is_started(&module), true);
CHECK_EQ(start_called, true);
// Start when already started (should return ERROR_NONE)
start_called = false;
CHECK_EQ(module_start(&module), ERROR_NONE);
CHECK_EQ(start_called, false); // start() function should NOT be called again
// Stop successful
CHECK_EQ(module_stop(&module), ERROR_NONE);
CHECK_EQ(module_is_started(&module), false);
CHECK_EQ(stop_called, true);
// Stop when already stopped (should return ERROR_NONE)
stop_called = false;
CHECK_EQ(module_stop(&module), ERROR_NONE);
CHECK_EQ(stop_called, false); // stop() function should NOT be called again
// Test failed start
test_start_result = ERROR_NOT_FOUND;
start_called = false;
CHECK_EQ(module_start(&module), ERROR_NOT_FOUND);
CHECK_EQ(module_is_started(&module), false);
CHECK_EQ(start_called, true);
// Test failed stop
test_start_result = ERROR_NONE;
CHECK_EQ(module_start(&module), ERROR_NONE);
test_stop_result = ERROR_NOT_SUPPORTED;
stop_called = false;
CHECK_EQ(module_stop(&module), ERROR_NOT_SUPPORTED);
CHECK_EQ(module_is_started(&module), true); // Should still be started if stop failed
CHECK_EQ(stop_called, true);
// Clean up: fix stop result so we can stop it
test_stop_result = ERROR_NONE;
CHECK_EQ(module_stop(&module), ERROR_NONE);
CHECK_EQ(module_destruct(&module), ERROR_NONE);
}
TEST_CASE("Global symbol resolution") {
static const struct ModuleSymbol test_symbols[] = {
DEFINE_MODULE_SYMBOL(symbol_test_function),
MODULE_SYMBOL_TERMINATOR
};
struct Module module = {
.name = "test_sym",
.start = test_start,
.stop = test_stop,
.symbols = test_symbols,
.internal = nullptr
};
REQUIRE_EQ(module_construct(&module), ERROR_NONE);
uintptr_t addr;
// Should fail as it is not added or started
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), false);
REQUIRE_EQ(module_add(&module), ERROR_NONE);
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), false);
REQUIRE_EQ(module_start(&module), ERROR_NONE);
// Resolvable now that the module is both added and started
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), true);
// Cleanup
CHECK_EQ(module_stop(&module), ERROR_NONE);
CHECK_EQ(module_remove(&module), ERROR_NONE);
CHECK_EQ(module_destruct(&module), ERROR_NONE);
}
TEST_CASE("module_ensure_started adds module to global ledger") {
start_called = false;
stop_called = false;
test_start_result = ERROR_NONE;
test_stop_result = ERROR_NONE;
static const struct ModuleSymbol test_symbols[] = {
DEFINE_MODULE_SYMBOL(symbol_test_function),
MODULE_SYMBOL_TERMINATOR
};
struct Module module = {
.name = "test_ensure_started",
.start = test_start,
.stop = test_stop,
.symbols = test_symbols,
.internal = nullptr
};
uintptr_t addr;
// Not resolvable before module_ensure_started is called
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), false);
// test_start() asserts module_add(&module) is already ERROR_INVALID_STATE by the
// time start() runs, proving module_add happens before module_start (not after).
start_add_order_check_module = &module;
CHECK_EQ(module_ensure_started(&module), ERROR_NONE);
start_add_order_check_module = nullptr;
CHECK_EQ(module_is_started(&module), true);
CHECK_EQ(start_called, true);
// Module must be both added to the ledger and started to be resolvable
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), true);
// Calling again should be idempotent: no duplicate start, still resolvable
start_called = false;
CHECK_EQ(module_ensure_started(&module), ERROR_NONE);
CHECK_EQ(start_called, false);
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), true);
// Cleanup
CHECK_EQ(module_stop(&module), ERROR_NONE);
CHECK_EQ(module_remove(&module), ERROR_NONE);
CHECK_EQ(module_destruct(&module), ERROR_NONE);
}
TEST_CASE("module_ensure_destructed removes module from global ledger") {
start_called = false;
stop_called = false;
test_start_result = ERROR_NONE;
test_stop_result = ERROR_NONE;
static const struct ModuleSymbol test_symbols[] = {
DEFINE_MODULE_SYMBOL(symbol_test_function),
MODULE_SYMBOL_TERMINATOR
};
struct Module module = {
.name = "test_ensure_destructed",
.start = test_start,
.stop = test_stop,
.symbols = test_symbols,
.internal = nullptr
};
CHECK_EQ(module_ensure_started(&module), ERROR_NONE);
uintptr_t addr;
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), true);
CHECK_EQ(module_ensure_destructed(&module), ERROR_NONE);
CHECK_EQ(module_is_started(&module), false);
CHECK_EQ(stop_called, true);
// Module must no longer be resolvable once destructed. Note: this alone doesn't
// prove removal from the ledger, since module_resolve_symbol_global() also skips
// non-started modules — a leaked-but-stopped ledger entry would look the same.
CHECK_EQ(module_resolve_symbol_global("symbol_test_function", &addr), false);
// Directly prove detachment from the ledger: if module_ensure_destructed had left
// the module in place, this module_add() would return ERROR_INVALID_STATE.
CHECK_EQ(module_add(&module), ERROR_NONE);
CHECK_EQ(module_remove(&module), ERROR_NONE);
// Calling again on an already-destructed module should be a no-op
CHECK_EQ(module_ensure_destructed(&module), ERROR_NONE);
}
@@ -0,0 +1,67 @@
#include "doctest.h"
#include <tactility/concurrent/mutex.h>
TEST_CASE("mutex_construct and mutex_destruct should properly set the handle") {
Mutex mutex = { 0 };
mutex_construct(&mutex);
CHECK_NE(mutex.handle, nullptr);
mutex_destruct(&mutex);
CHECK_EQ(mutex.handle, nullptr);
}
TEST_CASE("mutex_is_locked should return true only when the mutex is locked") {
Mutex mutex = { 0 };
mutex_construct(&mutex);
CHECK_EQ(mutex_is_locked(&mutex), false);
mutex_lock(&mutex);
CHECK_EQ(mutex_is_locked(&mutex), true);
mutex_unlock(&mutex);
CHECK_EQ(mutex_is_locked(&mutex), false);
mutex_destruct(&mutex);
}
TEST_CASE("mutex_try_lock should succeed on first lock but not on second") {
Mutex mutex = { 0 };
mutex_construct(&mutex);
CHECK_EQ(mutex_try_lock(&mutex, 0), true);
CHECK_EQ(mutex_try_lock(&mutex, 0), false);
mutex_unlock(&mutex);
mutex_destruct(&mutex);
}
TEST_CASE("mutex_lock in another task should block when a lock is active") {
static int task_lock_counter = 0;
Mutex mutex = { 0 };
task_lock_counter = 0;
mutex_construct(&mutex);
mutex_lock(&mutex);
TaskHandle_t task_handle;
auto task_create_result = xTaskCreate(
[](void* input) {
Mutex* mutex_ptr = static_cast<Mutex*>(input);
mutex_lock(mutex_ptr);
task_lock_counter++;
mutex_unlock(mutex_ptr);
vTaskDelete(nullptr);
},
"mutex_test",
2048,
&mutex,
0,
&task_handle
);
CHECK_EQ(task_create_result, pdPASS);
CHECK_EQ(task_lock_counter, 0);
mutex_unlock(&mutex);
vTaskDelay(2); // 1 is sufficient most of the time, but not always
CHECK_EQ(task_lock_counter, 1);
mutex_destruct(&mutex);
}
@@ -0,0 +1,31 @@
#include "doctest.h"
#include <cstring>
#include <tactility/paths.h>
// The simulator target is never built with ESP_PLATFORM, so paths_get_user_data_path()
// always takes the fixed "data" path branch here, guarded by a buffer-size check.
TEST_CASE("paths_get_user_data_path succeeds when the buffer exactly fits") {
char buffer[16] = { 0 };
CHECK_EQ(paths_get_user_data_path(buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "data"), 0);
}
TEST_CASE("paths_get_user_data_path succeeds with a buffer sized to exactly fit the string and terminator") {
char buffer[5] = { 0 }; // strlen("data") + 1
CHECK_EQ(paths_get_user_data_path(buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "data"), 0);
}
TEST_CASE("paths_get_user_data_path reports a buffer overflow when the buffer is one byte too small") {
char buffer[4] = { 0 }; // strlen("data"), no room for the terminator
CHECK_EQ(paths_get_user_data_path(buffer, sizeof(buffer)), ERROR_BUFFER_OVERFLOW);
}
TEST_CASE("paths_get_user_data_path reports a buffer overflow for a zero-size buffer") {
char buffer[1] = { 'x' };
CHECK_EQ(paths_get_user_data_path(buffer, 0), ERROR_BUFFER_OVERFLOW);
CHECK_EQ(buffer[0], 'x'); // untouched
}
@@ -0,0 +1,245 @@
#include "doctest.h"
#include <tactility/preferences.h>
#include <cstdio>
#include <cstring>
#include <sys/stat.h>
#include <unistd.h>
namespace {
const char* TEST_PATH = "/tmp/tactility_kernel_preferences_test.properties";
struct ScratchFile {
ScratchFile() { std::remove(TEST_PATH); }
~ScratchFile() { std::remove(TEST_PATH); }
};
bool file_exists(const char* path) {
FILE* file = std::fopen(path, "r");
if (file == nullptr) {
return false;
}
std::fclose(file);
return true;
}
bool is_directory(const char* path) {
struct stat info {};
return stat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFDIR;
}
// Writes a raw properties file directly (bypassing preferences_put_*()) so a test can exercise
// a hand-crafted/corrupted payload that preferences_put_*() itself would never produce.
void write_raw(const char* path, const char* content) {
FILE* file = std::fopen(path, "w");
REQUIRE(file != nullptr);
std::fputs(content, file);
std::fclose(file);
}
} // namespace
TEST_CASE("preferences_open_path on a missing file starts out empty, without creating it") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
CHECK_NE(preferences, nullptr);
CHECK_FALSE(preferences_has_bool(preferences, "key"));
CHECK_FALSE(file_exists(TEST_PATH));
preferences_close(preferences);
}
TEST_CASE("put_*/has_*/opt_* round-trip all four types") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_bool(preferences, "flag", true);
preferences_put_int32(preferences, "count", -42);
preferences_put_int64(preferences, "big", 123456789012345LL);
preferences_put_string(preferences, "text", "hello world");
CHECK(preferences_has_bool(preferences, "flag"));
bool bool_out = false;
CHECK(preferences_opt_bool(preferences, "flag", &bool_out));
CHECK_EQ(bool_out, true);
CHECK(preferences_has_int32(preferences, "count"));
int32_t int32_out = 0;
CHECK(preferences_opt_int32(preferences, "count", &int32_out));
CHECK_EQ(int32_out, -42);
CHECK(preferences_has_int64(preferences, "big"));
int64_t int64_out = 0;
CHECK(preferences_opt_int64(preferences, "big", &int64_out));
CHECK_EQ(int64_out, 123456789012345LL);
CHECK(preferences_has_string(preferences, "text"));
char buffer[32];
CHECK_EQ(preferences_opt_string(preferences, "text", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "hello world"), 0);
preferences_close(preferences);
}
TEST_CASE("opt_string reports ERROR_BUFFER_OVERFLOW and ERROR_NOT_FOUND") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_string(preferences, "text", "hello world");
char tiny[4];
CHECK_EQ(preferences_opt_string(preferences, "text", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
char buffer[32];
CHECK_EQ(preferences_opt_string(preferences, "missing", buffer, sizeof(buffer)), ERROR_NOT_FOUND);
preferences_close(preferences);
}
TEST_CASE("has_*/opt_* reject a key stored with a different type") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_bool(preferences, "key", true);
CHECK_FALSE(preferences_has_int32(preferences, "key"));
CHECK_FALSE(preferences_has_int64(preferences, "key"));
CHECK_FALSE(preferences_has_string(preferences, "key"));
int32_t out = 0;
CHECK_FALSE(preferences_opt_int32(preferences, "key", &out));
preferences_close(preferences);
}
TEST_CASE("has_*/opt_* reject malformed scalar payloads instead of misparsing them") {
ScratchFile scratch;
write_raw(TEST_PATH,
"bad_bool=b:garbage\n"
"bad_bool_2=b:2\n"
"trailing_junk=i32:42abc\n"
"int32_overflow=i32:5000000000\n"
"int64_overflow=i64:99999999999999999999\n"
"empty_int=i32:\n");
Preferences* preferences = preferences_open(TEST_PATH);
// "b:garbage" must not silently read back as false - has_bool()/opt_bool() must agree it's
// not a valid bool at all.
CHECK_FALSE(preferences_has_bool(preferences, "bad_bool"));
bool bool_out = true;
CHECK_FALSE(preferences_opt_bool(preferences, "bad_bool", &bool_out));
CHECK_FALSE(preferences_has_bool(preferences, "bad_bool_2"));
CHECK_FALSE(preferences_opt_bool(preferences, "bad_bool_2", &bool_out));
// "42abc" must not silently parse as 42 - the full payload must be consumed.
CHECK_FALSE(preferences_has_int32(preferences, "trailing_junk"));
int32_t int32_out = 0;
CHECK_FALSE(preferences_opt_int32(preferences, "trailing_junk", &int32_out));
// Fits in a (64-bit, on this platform) `long` but overflows int32_t - must not silently
// truncate on the narrowing cast.
CHECK_FALSE(preferences_has_int32(preferences, "int32_overflow"));
CHECK_FALSE(preferences_opt_int32(preferences, "int32_overflow", &int32_out));
// Overflows even a 64-bit integer - strtoll() itself reports ERANGE.
CHECK_FALSE(preferences_has_int64(preferences, "int64_overflow"));
int64_t int64_out = 0;
CHECK_FALSE(preferences_opt_int64(preferences, "int64_overflow", &int64_out));
CHECK_FALSE(preferences_has_int32(preferences, "empty_int"));
CHECK_FALSE(preferences_opt_int32(preferences, "empty_int", &int32_out));
preferences_close(preferences);
}
TEST_CASE("a string value with embedded newlines and backslashes survives a reopen") {
ScratchFile scratch;
{
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_string(preferences, "text", "line1\nline2 with \\ backslash");
preferences_close(preferences);
}
{
Preferences* preferences = preferences_open(TEST_PATH);
char buffer[64];
CHECK_EQ(preferences_opt_string(preferences, "text", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "line1\nline2 with \\ backslash"), 0);
preferences_close(preferences);
}
}
TEST_CASE("preferences_close persists changes, and only close persists them") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_bool(preferences, "flag", true);
// Not persisted yet - only preferences_close() writes to disk.
CHECK_FALSE(file_exists(TEST_PATH));
preferences_close(preferences);
CHECK(file_exists(TEST_PATH));
Preferences* reopened = preferences_open(TEST_PATH);
CHECK(preferences_has_bool(reopened, "flag"));
preferences_close(reopened);
}
TEST_CASE("put_* on a reopened instance is visible without reopening again, and persists") {
ScratchFile scratch;
Preferences* preferences = preferences_open(TEST_PATH);
preferences_put_int32(preferences, "count", 1);
preferences_close(preferences);
Preferences* reopened = preferences_open(TEST_PATH);
preferences_put_int32(reopened, "count", 2);
int32_t out = 0;
CHECK(preferences_opt_int32(reopened, "count", &out));
CHECK_EQ(out, 2);
preferences_close(reopened);
Preferences* final_instance = preferences_open(TEST_PATH);
CHECK(preferences_opt_int32(final_instance, "count", &out));
CHECK_EQ(out, 2);
preferences_close(final_instance);
}
TEST_CASE("preferences_open creates missing parent directories (recursively) and persists into them") {
const char* nested_dir_a = "/tmp/tactility_kernel_preferences_test_nested";
const char* nested_dir_b = "/tmp/tactility_kernel_preferences_test_nested/a";
const char* nested_dir_c = "/tmp/tactility_kernel_preferences_test_nested/a/b";
const char* nested_path = "/tmp/tactility_kernel_preferences_test_nested/a/b/settings.properties";
std::remove(nested_path);
rmdir(nested_dir_c);
rmdir(nested_dir_b);
rmdir(nested_dir_a);
REQUIRE_FALSE(is_directory(nested_dir_a));
Preferences* preferences = preferences_open(nested_path);
REQUIRE_NE(preferences, nullptr);
CHECK(is_directory(nested_dir_a));
CHECK(is_directory(nested_dir_b));
CHECK(is_directory(nested_dir_c));
preferences_put_int32(preferences, "count", 7);
preferences_close(preferences);
CHECK(file_exists(nested_path));
Preferences* reopened = preferences_open(nested_path);
REQUIRE_NE(reopened, nullptr);
int32_t out = 0;
CHECK(preferences_opt_int32(reopened, "count", &out));
CHECK_EQ(out, 7);
preferences_close(reopened);
std::remove(nested_path);
rmdir(nested_dir_c);
rmdir(nested_dir_b);
rmdir(nested_dir_a);
}
@@ -0,0 +1,259 @@
#include "doctest.h"
#include <tactility/properties_file.h>
#include <cstdio>
#include <cstring>
#include <string>
#include <sys/stat.h>
#include <unistd.h>
#include <utility>
#include <vector>
namespace {
const char* TEST_PATH = "/tmp/tactility_kernel_properties_file_test.properties";
struct ScratchFile {
ScratchFile() { std::remove(TEST_PATH); }
~ScratchFile() { std::remove(TEST_PATH); }
};
bool file_exists(const char* path) {
FILE* file = std::fopen(path, "r");
if (file == nullptr) {
return false;
}
std::fclose(file);
return true;
}
void write_raw(const char* path, const char* content) {
FILE* file = std::fopen(path, "w");
REQUIRE(file != nullptr);
std::fputs(content, file);
std::fclose(file);
}
} // namespace
TEST_CASE("properties_file_open on a missing file starts out empty, without creating it") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
CHECK_NE(file, nullptr);
CHECK_FALSE(properties_file_has(file, "key"));
CHECK_FALSE(file_exists(TEST_PATH));
properties_file_close(file);
}
TEST_CASE("properties_file_open returns NULL when a genuine I/O error interrupts reading") {
// fopen() on a directory succeeds on Linux, but the first read fails with EISDIR and sets
// the stream's error indicator - a deterministic way to exercise load_from_file()'s
// ferror() check without needing real storage-hardware fault injection.
const char* dir_path = "/tmp/tactility_kernel_properties_file_test_is_a_directory";
rmdir(dir_path);
REQUIRE_EQ(mkdir(dir_path, 0777), 0);
CHECK_EQ(properties_file_open(dir_path), nullptr);
rmdir(dir_path);
}
TEST_CASE("set/has/get round-trip, and close persists while unclosed changes don't") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "value");
CHECK(properties_file_has(file, "key"));
char buffer[32];
CHECK_EQ(properties_file_get(file, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "value"), 0);
// Not persisted yet - only properties_file_close() writes to disk.
CHECK_FALSE(file_exists(TEST_PATH));
properties_file_close(file);
CHECK(file_exists(TEST_PATH));
PropertiesFile* reopened = properties_file_open(TEST_PATH);
CHECK(properties_file_has(reopened, "key"));
properties_file_close(reopened);
}
TEST_CASE("properties_file_get reports ERROR_BUFFER_OVERFLOW and ERROR_NOT_FOUND") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "value");
char tiny[3];
CHECK_EQ(properties_file_get(file, "key", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
char buffer[32];
CHECK_EQ(properties_file_get(file, "missing", buffer, sizeof(buffer)), ERROR_NOT_FOUND);
properties_file_close(file);
}
TEST_CASE("set overwrites a previously stored value") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "first");
properties_file_set(file, "key", "second");
char buffer[32];
CHECK_EQ(properties_file_get(file, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "second"), 0);
properties_file_close(file);
}
TEST_CASE("comments and blank lines are skipped, keys and values are trimmed") {
ScratchFile scratch;
write_raw(TEST_PATH,
"# Comment\n"
" \t# Indented comment\n"
"\n"
"key1=value1\n"
" \tkey 2\t = \tvalue 2\t \n");
PropertiesFile* file = properties_file_open(TEST_PATH);
char buffer[32];
CHECK_EQ(properties_file_get(file, "key1", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "value1"), 0);
// Only leading/trailing whitespace is trimmed - the internal space in "key 2"/"value 2"
// survives.
CHECK_EQ(properties_file_get(file, "key 2", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "value 2"), 0);
properties_file_close(file);
}
TEST_CASE("a malformed line (no '=') is skipped without aborting the rest of the file") {
ScratchFile scratch;
write_raw(TEST_PATH, "not_a_key_value_pair\nkey=value\n");
PropertiesFile* file = properties_file_open(TEST_PATH);
CHECK(properties_file_has(file, "key"));
CHECK_FALSE(properties_file_has(file, "not_a_key_value_pair"));
properties_file_close(file);
}
TEST_CASE("a [section] line prefixes every following key until the next section") {
ScratchFile scratch;
write_raw(TEST_PATH,
"[app]\n"
"id=one.tactility.helloworld\n"
"name=Hello\n"
"[other]\n"
"id=x\n");
PropertiesFile* file = properties_file_open(TEST_PATH);
char buffer[64];
CHECK_EQ(properties_file_get(file, "[app]id", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "one.tactility.helloworld"), 0);
CHECK_EQ(properties_file_get(file, "[app]name", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "Hello"), 0);
CHECK_EQ(properties_file_get(file, "[other]id", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "x"), 0);
CHECK_FALSE(properties_file_has(file, "id"));
properties_file_close(file);
}
TEST_CASE("properties_file_close reports ERROR_NONE on success") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "key", "value");
CHECK_EQ(properties_file_close(file), ERROR_NONE);
}
TEST_CASE("properties_file_close reports ERROR_RESOURCE when the parent directory doesn't exist") {
const char* path = "/tmp/tactility_kernel_properties_file_test_missing_dir/settings.properties";
std::remove(path); // no-op if the directory doesn't exist, which is the point of this test
// Missing directory is not an error for open() - it starts out empty, same as a missing
// file (see the "starts out empty" test above).
PropertiesFile* file = properties_file_open(path);
REQUIRE_NE(file, nullptr);
properties_file_set(file, "key", "value");
// close()'s save can't create its temp file in a directory that doesn't exist.
CHECK_EQ(properties_file_close(file), ERROR_RESOURCE);
}
TEST_CASE("a failed close leaves previously-saved content on disk untouched") {
if (geteuid() == 0) {
// Root bypasses directory write permissions, so the read-only directory below would
// not make save_to_file() fail.
return;
}
const char* dir = "/tmp/tactility_kernel_properties_file_readonly_test";
const char* path = "/tmp/tactility_kernel_properties_file_readonly_test/settings.properties";
mkdir(dir, 0700);
chmod(dir, 0700);
std::remove(path);
{
PropertiesFile* file = properties_file_open(path);
properties_file_set(file, "key", "original");
REQUIRE_EQ(properties_file_close(file), ERROR_NONE);
}
// Read-only directory - save_to_file()'s temp file can't be created there, so the close
// below must fail without disturbing the "original" content already on disk.
REQUIRE_EQ(chmod(dir, 0555), 0);
PropertiesFile* file = properties_file_open(path);
properties_file_set(file, "key", "corrupted");
CHECK_EQ(properties_file_close(file), ERROR_RESOURCE);
chmod(dir, 0700); // restore write access for the check below and for cleanup
PropertiesFile* reloaded = properties_file_open(path);
char buffer[32];
CHECK_EQ(properties_file_get(reloaded, "key", buffer, sizeof(buffer)), ERROR_NONE);
CHECK_EQ(std::strcmp(buffer, "original"), 0);
properties_file_close(reloaded);
std::remove(path);
rmdir(dir);
}
TEST_CASE("properties_file_for_each visits every key exactly once") {
ScratchFile scratch;
PropertiesFile* file = properties_file_open(TEST_PATH);
properties_file_set(file, "a", "1");
properties_file_set(file, "b", "2");
properties_file_set(file, "c", "3");
std::vector<std::pair<std::string, std::string>> seen;
properties_file_for_each(file, [](const char* key, const char* value, void* context) {
auto* out = static_cast<std::vector<std::pair<std::string, std::string>>*>(context);
out->emplace_back(key, value);
}, &seen);
CHECK_EQ(seen.size(), 3);
for (const auto& [key, value] : seen) {
if (key == "a") CHECK_EQ(value, "1");
else if (key == "b") CHECK_EQ(value, "2");
else if (key == "c") CHECK_EQ(value, "3");
else FAIL("unexpected key: " << key);
}
properties_file_close(file);
}
@@ -0,0 +1,87 @@
#include "doctest.h"
#include <tactility/concurrent/recursive_mutex.h>
TEST_CASE("recursive_mutex_construct and mutex_destruct should properly set the handle") {
RecursiveMutex mutex = { 0 };
recursive_mutex_construct(&mutex);
CHECK_NE(mutex.handle, nullptr);
recursive_mutex_destruct(&mutex);
CHECK_EQ(mutex.handle, nullptr);
}
TEST_CASE("recursive_mutex_is_locked should return true only when the mutex is locked") {
RecursiveMutex mutex = { 0 };
recursive_mutex_construct(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), false);
recursive_mutex_lock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), true);
recursive_mutex_unlock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), false);
recursive_mutex_destruct(&mutex);
}
TEST_CASE("recursive_mutex_is_locked can lock twice from the same thread") {
RecursiveMutex mutex = { 0 };
recursive_mutex_construct(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), false);
recursive_mutex_lock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), true);
recursive_mutex_lock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), true);
recursive_mutex_unlock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), true);
recursive_mutex_unlock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), false);
recursive_mutex_destruct(&mutex);
}
TEST_CASE("recursive_mutex_try_lock should lock multiple times from the same thread") {
RecursiveMutex mutex = { 0 };
recursive_mutex_construct(&mutex);
CHECK_EQ(recursive_mutex_try_lock(&mutex, 0), true);
CHECK_EQ(recursive_mutex_try_lock(&mutex, 0), true);
recursive_mutex_unlock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), true);
recursive_mutex_unlock(&mutex);
CHECK_EQ(recursive_mutex_is_locked(&mutex), false);
recursive_mutex_destruct(&mutex);
}
TEST_CASE("recursive_mutex_lock in another task should block when a lock is active") {
static int task_lock_counter = 0;
RecursiveMutex mutex = { 0 };
task_lock_counter = 0;
recursive_mutex_construct(&mutex);
recursive_mutex_lock(&mutex);
TaskHandle_t task_handle;
auto task_create_result = xTaskCreate(
[](void* input) {
RecursiveMutex* mutex_ptr = static_cast<RecursiveMutex*>(input);
recursive_mutex_lock(mutex_ptr);
task_lock_counter++;
recursive_mutex_unlock(mutex_ptr);
vTaskDelete(nullptr);
},
"mutex_test",
2048,
&mutex,
0,
&task_handle
);
CHECK_EQ(task_create_result, pdPASS);
CHECK_EQ(task_lock_counter, 0);
recursive_mutex_unlock(&mutex);
vTaskDelay(2); // 1 is sufficient most of the time, but not always
CHECK_EQ(task_lock_counter, 1);
recursive_mutex_destruct(&mutex);
}
@@ -0,0 +1,439 @@
#include "doctest.h"
#include <tactility/concurrent/thread.h>
#include <tactility/delay.h>
#include <tactility/system_event.h>
#include <tactility/time.h>
#include <vector>
// system_event_emit() snapshots matching subscriptions under the lock, then invokes them
// after unlocking (see the @warning on system_event_callback_add() in system_event.h), so a
// callback calling system_event_callback_add()/_unsubscribe()/_emit() must not deadlock -
// covered below, mirroring DeviceListenerTest.cpp's reentrancy test.
struct RecordedCall {
void* context;
SystemEventType type;
// Copied out of event->data during the callback - event->data is only valid for the
// duration of the callback (it lives in system_event_emit()'s own stack frame), so a bare
// pointer/length pair recorded here would dangle by the time a TEST_CASE inspects it.
std::vector<uint8_t> data;
uint64_t timestamp;
};
static std::vector<RecordedCall> calls_a;
static std::vector<RecordedCall> calls_b;
static void listener_a(SystemEvent* event, void* context) {
calls_a.push_back({ context, event->type, std::vector<uint8_t>(event->data, event->data + event->data_len), event->timestamp });
}
static void listener_b(SystemEvent* event, void* context) {
calls_b.push_back({ context, event->type, std::vector<uint8_t>(event->data, event->data + event->data_len), event->timestamp });
}
static void reset_calls() {
calls_a.clear();
calls_b.clear();
}
TEST_CASE("system_event_emit invokes every subscriber registered for that type") {
reset_calls();
int context_a = 1;
int context_b = 2;
CHECK_EQ(system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a), ERROR_NONE);
CHECK_EQ(system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b), ERROR_NONE);
CHECK_EQ(system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0), ERROR_NONE);
REQUIRE_EQ(calls_a.size(), 1);
CHECK_EQ(calls_a[0].context, &context_a);
CHECK_EQ(calls_a[0].type, KERNEL_EVENT_BOOT_COMPLETED);
REQUIRE_EQ(calls_b.size(), 1);
CHECK_EQ(calls_b[0].context, &context_b);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
}
TEST_CASE("system_event_emit only invokes subscribers registered for the emitted type") {
reset_calls();
int context_a = 1;
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
CHECK_EQ(calls_a.size(), 0);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_a.size(), 1);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
}
TEST_CASE("system_event_emit copies the data into the delivered event") {
reset_calls();
int context_a = 1;
struct Payload { int value; } payload { 42 };
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, &payload, sizeof(payload));
REQUIRE_EQ(calls_a.size(), 1);
REQUIRE_EQ(calls_a[0].data.size(), sizeof(payload));
CHECK_EQ(reinterpret_cast<const Payload*>(calls_a[0].data.data())->value, 42);
system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_a);
}
TEST_CASE("system_event_emit with no data delivers an empty payload") {
reset_calls();
int context_a = 1;
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
REQUIRE_EQ(calls_a.size(), 1);
CHECK(calls_a[0].data.empty());
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
}
TEST_CASE("system_event_callback_remove stops further notifications for that callback only") {
reset_calls();
int context_a = 1;
int context_b = 2;
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b);
CHECK_EQ(system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NONE);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_a.size(), 0);
CHECK_EQ(calls_b.size(), 1);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
}
TEST_CASE("system_event_callback_remove on an unregistered callback returns ERROR_NOT_FOUND and is a no-op") {
reset_calls();
int context_b = 2;
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b);
// listener_a was never added for this type, so removing it must not disturb listener_b.
CHECK_EQ(system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NOT_FOUND);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_b.size(), 1);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
}
TEST_CASE("system_event_callback_remove matches on (type, callback), not the callback alone") {
reset_calls();
int context_a = 1;
// Same callback subscribed for two different event types.
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_a.size(), 0);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
CHECK_EQ(calls_a.size(), 1);
system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_a);
}
TEST_CASE("system_event_emit with no subscribers for that type returns ERROR_NONE") {
CHECK_EQ(system_event_emit(KERNEL_EVENT_SERVICE_STOPPED, nullptr, 0), ERROR_NONE);
}
TEST_CASE("system_event_emit stamps the event with the current boot-relative time") {
reset_calls();
int context_a = 1;
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a);
auto before = static_cast<uint64_t>(get_micros_since_boot());
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
auto after = static_cast<uint64_t>(get_micros_since_boot());
REQUIRE_EQ(calls_a.size(), 1);
CHECK_GE(calls_a[0].timestamp, before);
CHECK_LE(calls_a[0].timestamp, after);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a);
}
static bool reentrant_add_triggered = false;
static void reentrant_listener(SystemEvent* event, void* context) {
calls_a.push_back({ context, event->type, std::vector<uint8_t>(event->data, event->data + event->data_len), event->timestamp });
if (!reentrant_add_triggered) {
reentrant_add_triggered = true;
// Subscribing from within a notification must not deadlock: emit() releases the
// lock before invoking callbacks, so this only blocks briefly on the (already
// unlocked) mutex.
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, context);
// Also exercise unsubscribe() and a nested emit() of a different type from within
// a callback - all must complete without deadlocking.
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
}
}
TEST_CASE("system_event_emit is safe when a callback subscribes, unsubscribes and emits during notification") {
reset_calls();
reentrant_add_triggered = false;
int context_a = 1;
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener, &context_a);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_b, &context_a);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
// reentrant_listener unsubscribed itself and triggered a nested TIME_CHANGED emit,
// which the pre-existing listener_b subscription picks up. The listener_b
// subscription added *during* this round wasn't part of this round's snapshot, so it
// wasn't invoked for BOOT_COMPLETED yet.
CHECK_EQ(calls_a.size(), 1);
CHECK_EQ(calls_b.size(), 1);
// A second BOOT_COMPLETED emit must not reach reentrant_listener again (it
// unsubscribed itself), but must reach the listener_b subscription added last round.
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
CHECK_EQ(calls_a.size(), 1);
CHECK_EQ(calls_b.size(), 2);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b);
system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_b);
}
// gps.h-style poll subscription: system_event_subscribe()/_await()/_unsubscribe().
//
// system_event_await() only detects sequence increments that happen *after* it starts
// waiting (same as gps_api_event_await()), so the emit must be started from another task
// while this one is already blocked in await() - emitting first and awaiting after would
// race the notification the same way it would with any FreeRTOS task-notify consumer.
TEST_CASE("system_event_subscribe/_await deliver the event payload by value") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_NETWORK_CONNECTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
NetworkConnectedEvent connected { .device = nullptr, .ipv4_addr = 0x0A000001, .gateway = 0x0A0000FE };
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void* context) {
delay_millis(20);
auto* connected_ptr = static_cast<NetworkConnectedEvent*>(context);
system_event_emit(KERNEL_EVENT_NETWORK_CONNECTED, connected_ptr, sizeof(*connected_ptr));
return 0;
},
&connected,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
NetworkConnectedEvent received {};
CHECK_EQ(system_event_get_data(&sub, reinterpret_cast<uint8_t*>(&received), sizeof(received)), ERROR_NONE);
CHECK_EQ(received.ipv4_addr, connected.ipv4_addr);
CHECK_EQ(received.gateway, connected.gateway);
CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE);
thread_free(thread);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NOT_FOUND);
}
TEST_CASE("system_event_await returns a matching event that arrived before it started waiting") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_NETWORK_CONNECTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
// Same-thread emit, no background thread needed: unlike the "detects a change after it
// starts waiting" tests above, this is exactly the case system_event_await() must handle -
// sequence already moved ahead of consumed_sequence before await() is even called.
NetworkConnectedEvent connected { .device = nullptr, .ipv4_addr = 0x0A000001, .gateway = 0x0A0000FE };
CHECK_EQ(system_event_emit(KERNEL_EVENT_NETWORK_CONNECTED, &connected, sizeof(connected)), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, 0), ERROR_NONE);
NetworkConnectedEvent received {};
CHECK_EQ(system_event_get_data(&sub, reinterpret_cast<uint8_t*>(&received), sizeof(received)), ERROR_NONE);
CHECK_EQ(received.ipv4_addr, connected.ipv4_addr);
CHECK_EQ(received.gateway, connected.gateway);
// The pending event was consumed by the call above - a second await() with no further
// emit must time out rather than returning the same event again.
CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_await times out when no matching event has arrived") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_TIME_CHANGED;
system_event_subscribe(&sub);
CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_emit does not notify a poll subscriber of a different type") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_BOOT_COMPLETED;
system_event_subscribe(&sub);
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_get_data reports ERROR_BUFFER_OVERFLOW and leaves the buffer untouched") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_NETWORK_DISCONNECTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
// system_event_await() only detects sequence increments that happen *after* it starts
// waiting (see the comment above), so the emit must come from another task while this one
// is already blocked in await() - same pattern as the payload-delivery test above.
NetworkDisconnectedEvent disconnected { .device = nullptr };
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void* context) {
delay_millis(20);
auto* disconnected_ptr = static_cast<NetworkDisconnectedEvent*>(context);
system_event_emit(KERNEL_EVENT_NETWORK_DISCONNECTED, disconnected_ptr, sizeof(*disconnected_ptr));
return 0;
},
&disconnected,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE);
thread_free(thread);
uint8_t tiny[1] = { 0xAA };
CHECK_EQ(system_event_get_data(&sub, tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW);
CHECK_EQ(tiny[0], 0xAA);
uint8_t exact[sizeof(NetworkDisconnectedEvent)];
CHECK_EQ(system_event_get_data(&sub, exact, sizeof(exact)), ERROR_NONE);
system_event_unsubscribe(&sub);
}
TEST_CASE("system_event_get_data on a subscription with no payload copies nothing and succeeds") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_BOOT_COMPLETED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void*) {
delay_millis(20);
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
return 0;
},
nullptr,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE);
thread_free(thread);
uint8_t buffer[1] = { 0x42 };
CHECK_EQ(system_event_get_data(&sub, buffer, 0), ERROR_NONE);
CHECK_EQ(buffer[0], 0x42); // untouched - nothing to copy
system_event_unsubscribe(&sub);
}
// Regression coverage for system_event_unsubscribe() racing a task blocked in
// system_event_await() on the same subscription, and for reusing a subscription node after
// unsubscribing it - see the @warning on system_event_unsubscribe() in system_event.h.
TEST_CASE("system_event_unsubscribe wakes a task blocked in system_event_await with ERROR_INVALID_STATE") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_SERVICE_STARTED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
auto* thread = thread_alloc_full(
"system-event-awaiter",
4096,
[](void* context) {
auto* awaited_sub = static_cast<SystemEventSubscription*>(context);
// Long timeout - the point is that unsubscribe() wakes this early, not that it
// eventually times out on its own.
return static_cast<int32_t>(system_event_await(awaited_sub, pdMS_TO_TICKS(5000)));
},
&sub,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
// Give the awaiter task a moment to actually reach xSemaphoreTake() before unsubscribing -
// otherwise this test wouldn't exercise the "already blocked" race at all.
delay_millis(20);
// Must return promptly (nudging the blocked awaiter awake), not by waiting out its timeout.
TickType_t before = get_ticks();
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
CHECK_LT(get_ticks() - before, pdMS_TO_TICKS(1000));
CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE);
CHECK_EQ(thread_get_return_code(thread), ERROR_INVALID_STATE);
thread_free(thread);
// A second unsubscribe() has nothing left to do.
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NOT_FOUND);
}
TEST_CASE("a subscription node can be re-subscribed after system_event_unsubscribe") {
SystemEventSubscription sub {};
sub.event.type = KERNEL_EVENT_SERVICE_STOPPED;
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
// Re-registering the same node (same storage, not a fresh SystemEventSubscription) must
// work as if it were new - a fresh semaphore, and no leftover `cancelled` state from the
// unsubscribe() above causing an immediate spurious ERROR_INVALID_STATE below.
CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE);
auto* thread = thread_alloc_full(
"system-event-emitter",
4096,
[](void*) {
delay_millis(20);
system_event_emit(KERNEL_EVENT_SERVICE_STOPPED, nullptr, 0);
return 0;
},
nullptr,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE);
CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE);
thread_free(thread);
CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE);
}
@@ -0,0 +1,112 @@
#include "doctest.h"
#include <tactility/delay.h>
#include <tactility/concurrent/thread.h>
TEST_CASE("when a thread is started then its callback should be called") {
bool has_called = false;
auto* thread = thread_alloc_full(
"immediate return task",
4096,
[](void* context) {
auto* has_called_ptr = static_cast<bool*>(context);
*has_called_ptr = true;
return 0;
},
&has_called,
-1
);
CHECK(!has_called);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE);
thread_free(thread);
CHECK(has_called);
}
TEST_CASE("a thread can be started and stopped") {
bool interrupted = false;
auto* thread = thread_alloc_full(
"interruptable thread",
4096,
[](void* context) {
auto* interrupted_ptr = static_cast<bool*>(context);
while (!*interrupted_ptr) {
delay_millis(1);
}
return 0;
},
&interrupted,
-1
);
CHECK(thread);
CHECK_EQ(thread_start(thread), ERROR_NONE);
interrupted = true;
CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE);
thread_free(thread);
}
TEST_CASE("thread id should only be set at when thread is started") {
bool interrupted = false;
auto* thread = thread_alloc_full(
"interruptable thread",
4096,
[](void* context) {
auto* interrupted_ptr = static_cast<bool*>(context);
while (!*interrupted_ptr) {
delay_millis(1);
}
return 0;
},
&interrupted,
-1
);
CHECK_EQ(thread_get_task_handle(thread), nullptr);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_NE(thread_get_task_handle(thread), nullptr);
interrupted = true;
CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE);
CHECK_EQ(thread_get_task_handle(thread), nullptr);
thread_free(thread);
}
TEST_CASE("thread state should be correct") {
bool interrupted = false;
auto* thread = thread_alloc_full(
"interruptable thread",
4096,
[](void* context) {
auto* interrupted_ptr = static_cast<bool*>(context);
while (!*interrupted_ptr) {
delay_millis(1);
}
return 0;
},
&interrupted,
-1
);
CHECK_EQ(thread_get_state(thread), THREAD_STATE_STOPPED);
thread_start(thread);
auto state = thread_get_state(thread);
CHECK((state == THREAD_STATE_STARTING || state == THREAD_STATE_RUNNING));
interrupted = true;
CHECK_EQ(thread_join(thread, 10, 1), ERROR_NONE);
CHECK_EQ(thread_get_state(thread), THREAD_STATE_STOPPED);
thread_free(thread);
}
TEST_CASE("thread return code should be available after it is joined") {
auto* thread = thread_alloc_full(
"return code",
4096,
[](void* context) { return 123; },
nullptr,
-1
);
CHECK_EQ(thread_start(thread), ERROR_NONE);
CHECK_EQ(thread_join(thread, 1, 1), ERROR_NONE);
CHECK_EQ(thread_get_return_code(thread), 123);
thread_free(thread);
}
@@ -0,0 +1,30 @@
#include "doctest.h"
#include <tactility/delay.h>
#include <tactility/time.h>
TEST_CASE("delay ticks should be accurate within 1 tick") {
auto start_time = get_ticks();
delay_ticks(100);
auto end_time = get_ticks();
auto difference = end_time - start_time;
CHECK_EQ(difference >= 100, true);
CHECK_EQ(difference <= 101, true);
}
TEST_CASE("delay millis should be accurate within 1 tick") {
auto start_time = get_millis();
delay_millis(100);
auto end_time = get_millis();
auto difference = end_time - start_time;
CHECK_EQ(difference >= 100, true);
CHECK_EQ(difference <= 101, true);
}
TEST_CASE("microsecond time should be accurate within 1 tick") {
auto start_time = get_micros_since_boot();
delay_millis(100);
auto end_time = get_micros_since_boot();
auto difference = (end_time - start_time) / 1000;
CHECK_EQ(difference >= 99, true);
CHECK_EQ(difference <= 101, true);
}
+147
View File
@@ -0,0 +1,147 @@
#include "doctest.h"
#include <atomic>
#include <tactility/concurrent/timer.h>
#include <tactility/delay.h>
TEST_CASE("timer_alloc and timer_free should handle allocation and deallocation") {
auto callback = [](void* context) {};
struct Timer* timer = timer_alloc(TIMER_TYPE_ONCE, 10, callback, nullptr);
CHECK_NE(timer, nullptr);
timer_free(timer);
}
TEST_CASE("timer_start and timer_stop should change running state") {
auto callback = [](void* context) {};
struct Timer* timer = timer_alloc(TIMER_TYPE_ONCE, 10, callback, nullptr);
REQUIRE_NE(timer, nullptr);
CHECK_EQ(timer_is_running(timer), false);
CHECK_EQ(timer_start(timer), ERROR_NONE);
CHECK_EQ(timer_is_running(timer), true);
CHECK_EQ(timer_stop(timer), ERROR_NONE);
CHECK_EQ(timer_is_running(timer), false);
timer_free(timer);
}
TEST_CASE("one-shot timer should fire callback once") {
std::atomic<int> call_count{0};
struct Timer* timer = timer_alloc(TIMER_TYPE_ONCE, 10, [](void* context) {
auto* count = static_cast<std::atomic<int>*>(context);
(*count)++;
}, &call_count);
REQUIRE_NE(timer, nullptr);
CHECK_EQ(timer_start(timer), ERROR_NONE);
delay_millis(20);
CHECK_EQ(call_count.load(), 1);
CHECK_EQ(timer_is_running(timer), false);
timer_free(timer);
}
TEST_CASE("periodic timer should fire callback multiple times") {
std::atomic<int> call_count{0};
struct Timer* timer = timer_alloc(TIMER_TYPE_PERIODIC, 10, [](void* context) {
auto* count = static_cast<std::atomic<int>*>(context);
(*count)++;
}, &call_count);
REQUIRE_NE(timer, nullptr);
CHECK_EQ(timer_start(timer), ERROR_NONE);
delay_millis(35); // Should fire around 3 times
CHECK_GE(call_count.load(), 3);
CHECK_EQ(timer_is_running(timer), true);
timer_stop(timer);
timer_free(timer);
}
TEST_CASE("timer_reset should restart the timer") {
std::atomic<int> call_count{0};
struct Timer* timer = timer_alloc(TIMER_TYPE_ONCE, 20, [](void* context) {
auto* count = static_cast<std::atomic<int>*>(context);
(*count)++;
}, &call_count);
REQUIRE_NE(timer, nullptr);
CHECK_EQ(timer_start(timer), ERROR_NONE);
delay_millis(10);
CHECK_EQ(call_count.load(), 0);
// Resetting should push the expiry further
CHECK_EQ(timer_reset(timer), ERROR_NONE);
delay_millis(15);
CHECK_EQ(call_count.load(), 0); // Still shouldn't have fired if reset worked
delay_millis(10);
CHECK_EQ(call_count.load(), 1); // Now it should have fired
timer_free(timer);
}
TEST_CASE("timer_reset_with_interval should change the period") {
std::atomic<int> call_count{0};
struct Timer* timer = timer_alloc(TIMER_TYPE_ONCE, 40, [](void* context) {
auto* count = static_cast<std::atomic<int>*>(context);
(*count)++;
}, &call_count);
REQUIRE_NE(timer, nullptr);
CHECK_EQ(timer_start(timer), ERROR_NONE);
// Change to a much shorter interval
CHECK_EQ(timer_reset_with_interval(timer, 10), ERROR_NONE);
delay_millis(20);
CHECK_EQ(call_count.load(), 1);
timer_free(timer);
}
TEST_CASE("timer_get_expiry_time should return a valid time") {
struct Timer* timer = timer_alloc(TIMER_TYPE_ONCE, 10, [](void* context) {}, nullptr);
REQUIRE_NE(timer, nullptr);
timer_start(timer);
TickType_t expiry = timer_get_expiry_time(timer);
// Expiry should be in the future
CHECK_GT(expiry, xTaskGetTickCount());
timer_free(timer);
}
TEST_CASE("timer_set_pending_callback should execute callback in timer task") {
std::atomic<bool> called{false};
struct Context {
std::atomic<bool>* called;
uint32_t expected_arg;
uint32_t received_arg;
} context = { &called, 0x12345678, 0 };
auto pending_cb = [](void* ctx, uint32_t arg) {
auto* c = static_cast<Context*>(ctx);
c->received_arg = arg;
c->called->store(true);
};
// timer_set_pending_callback doesn't actually use the timer object in current implementation
// but we need one for the API
struct Timer* timer = timer_alloc(TIMER_TYPE_ONCE, 10, [](void* context) {}, nullptr);
CHECK_EQ(timer_set_pending_callback(timer, pending_cb, &context, context.expected_arg, portMAX_DELAY), ERROR_NONE);
// Wait for timer task to process the callback
int retries = 10;
while (!called.load() && retries-- > 0) {
delay_millis(10);
}
CHECK(called.load());
CHECK_EQ(context.received_arg, context.expected_arg);
timer_free(timer);
}