Alex2ESP/test/test_dispatch/test_main.cpp
David d3e7ee7f2a AlexaDevice: capability array, linked list, a directive handler with the device in hand
onDirective(handler) registers void handler(AlexaDirective&): the directive comes with its device, the
capability it is for, namespace, name, instance, correlationToken and payload, and response() and
stateReport() build the answer, so one function serves several devices. registerEvent() is kept; the
five examples compile unchanged and announce the same discovery objects.
Devices are a linked list on the bridge and a device holds ALEX2ESP_MAX_CAPABILITIES (8) capability
pointers in place of two std::deque; one capability more is refused with an error.
A device without a handler, and a handler that sends nothing for a capability the device lacks, are
answered with the ErrorResponse INVALID_DIRECTIVE instead of a timeout. A directive whose endpointId is
not a device of the sketch is ignored at DEBUG.
basicLight on a D1 mini: static RAM 30,672 -> 30,600 B, flash 332,541 -> 333,437 B, 0 warnings in the five
examples; 88 host tests (11 new, test/test_dispatch).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 19:45:57 +00:00

438 lines
17 KiB
C++

// Host tests of dispatch: which device and which capability a directive reaches, what the handler is given, what
// a device holds, and what the device answers itself (src/AlexaDevice.cpp). The transport of the devices is a
// fake that keeps what would have been published.
// pio test -e native
#include <unity.h>
#include <ArduinoJson.h>
#include <stdio.h>
#include <string.h>
#include <string>
#include <vector>
#include "AlexaDevice.h"
#include "AlexaLog.h"
class FakeTransport : public AlexaTransport
{
public:
struct Message
{
std::string topic;
std::string json;
};
std::vector<Message> sent;
AlexaSendResult publish(const char *topic, JsonDocument &doc) override
{
Message message;
message.topic = topic;
serializeJson(doc, message.json);
sent.push_back(message);
return AlexaSendResult::OK;
}
void timestamp(char *buffer, size_t size) override { snprintf(buffer, size, "2026-09-28T13:05:09Z"); }
};
class CapturedLog : public Print
{
public:
std::string text;
size_t write(const uint8_t *buffer, size_t size) override
{
text.append(reinterpret_cast<const char *>(buffer), size);
return size;
}
};
static void assertLogged(const CapturedLog &log, const char *expected)
{
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(log.text.c_str(), expected), log.text.c_str());
}
// What the handler of the tests was given, copied: the texts of a directive end with the handler
struct Seen
{
int calls;
AlexaDevice *device;
AlexaCapability *capability;
AlexaInterfaceType type;
std::string ns;
std::string name;
std::string instance;
std::string correlationToken;
int rangeValue;
bool reportState;
};
static Seen seen;
static bool handlerAnswers;
static void onDirective(AlexaDirective &directive)
{
seen.calls++;
seen.device = directive.device;
seen.capability = directive.capability;
seen.type = directive.type;
seen.ns = directive.ns;
seen.name = directive.name;
seen.instance = directive.instance;
seen.correlationToken = directive.correlationToken;
seen.rangeValue = directive.payload["rangeValue"] | -1;
seen.reportState = directive.isReportState();
if (!handlerAnswers)
{
return;
}
if (directive.isReportState())
{
directive.stateReport().AddPowerControllerProp(PowerController::OFF).send();
}
else
{
directive.response().AddPowerControllerProp(directive.is("TurnOn") ? PowerController::ON : PowerController::OFF).send();
}
}
static int eventCalls;
static int reportStateCalls;
static AlexaInterfaceType eventType;
static void onEvent(const JsonDocument &, const AlexaInterfaceType &type)
{
eventCalls++;
eventType = type;
}
static void onReportState(const JsonDocument &, const AlexaInterfaceType &)
{
reportStateCalls++;
}
// The directive as Alex2MQTT publishes it on <root>/<endpointId>/alexaDirective
static void directiveFor(JsonDocument &message, const char *endpointId, const char *ns, const char *name,
const char *instance = nullptr)
{
message.clear();
message["header"]["namespace"] = ns;
message["header"]["name"] = name;
message["header"]["payloadVersion"] = "3";
message["header"]["messageId"] = "5f8a426e-01e4-4cc9-8b79-65f8bd0fd8a4";
message["header"]["correlationToken"] = "token-of-the-directive";
if (instance != nullptr)
{
message["header"]["instance"] = instance;
}
message["endpoint"]["endpointId"] = endpointId;
message["payload"].to<JsonObject>();
}
static void assertErrorResponse(const FakeTransport::Message &message, const char *topic)
{
TEST_ASSERT_EQUAL_STRING(topic, message.topic.c_str());
JsonDocument answer;
TEST_ASSERT_TRUE(deserializeJson(answer, message.json) == DeserializationError::Ok);
TEST_ASSERT_EQUAL_STRING("Alexa", answer["event"]["header"]["namespace"] | "");
TEST_ASSERT_EQUAL_STRING("ErrorResponse", answer["event"]["header"]["name"] | "");
TEST_ASSERT_EQUAL_STRING("token-of-the-directive", answer["event"]["header"]["correlationToken"] | "");
TEST_ASSERT_EQUAL_STRING("INVALID_DIRECTIVE", answer["event"]["payload"]["type"] | "");
TEST_ASSERT_TRUE(strlen(answer["event"]["payload"]["message"] | "") > 0);
TEST_ASSERT_TRUE(answer["context"].isNull()); // alexa-errorresponse.html: an ErrorResponse has no context
}
void setUp(void)
{
AlexaLog::setOutput(nullptr);
seen = Seen();
handlerAnswers = true;
eventCalls = 0;
reportStateCalls = 0;
eventType = AlexaInterfaceType::UNKNOWN;
}
void tearDown(void) {}
void test_devices_are_found_by_their_endpoint_id_in_the_order_they_were_created(void)
{
AlexaDeviceList devices;
TEST_ASSERT_NULL(devices.first());
AlexaDevice *lamp = devices.add("Lamp", "root", "ESP-01", nullptr);
AlexaDevice *fan = devices.add("Fan", "root", "ESP-02", nullptr);
AlexaDevice *blind = devices.add("Blind", "root", "ESP-03", nullptr);
TEST_ASSERT_EQUAL(3, devices.size());
TEST_ASSERT_EQUAL_PTR(lamp, devices.first());
TEST_ASSERT_EQUAL_PTR(fan, lamp->nextDevice());
TEST_ASSERT_EQUAL_PTR(blind, fan->nextDevice());
TEST_ASSERT_NULL(blind->nextDevice());
TEST_ASSERT_EQUAL_PTR(fan, devices.find("ESP-02", 6));
// The id is a part of a topic: "ESP-02/alexaDirective"
TEST_ASSERT_EQUAL_PTR(fan, devices.find("ESP-02/alexaDirective", 6));
// An endpoint of another board
TEST_ASSERT_NULL(devices.find("ESP-04", 6));
TEST_ASSERT_NULL(devices.find("ESP-0", 5));
}
void test_one_handler_serves_two_devices_and_is_told_which(void)
{
FakeTransport transport;
AlexaDeviceList devices;
AlexaDevice *lamp = devices.add("Lamp", "root", "ESP-01", &transport);
AlexaDevice *fan = devices.add("Fan", "root", "ESP-02", &transport);
lamp->addCapability(AlexaInterfaces::PowerController);
AlexaCapability *fanPower = fan->addCapability(AlexaInterfaces::PowerController);
lamp->onDirective(onDirective);
fan->onDirective(onDirective);
JsonDocument message;
directiveFor(message, "ESP-02", "Alexa.PowerController", "TurnOn");
devices.find("ESP-02", 6)->handleDirective(message);
TEST_ASSERT_EQUAL(1, seen.calls);
TEST_ASSERT_EQUAL_PTR(fan, seen.device);
TEST_ASSERT_EQUAL_PTR(fanPower, seen.capability);
TEST_ASSERT_TRUE(AlexaInterfaceType::POWER_CONTROLLER == seen.type);
TEST_ASSERT_EQUAL_STRING("Alexa.PowerController", seen.ns.c_str());
TEST_ASSERT_EQUAL_STRING("TurnOn", seen.name.c_str());
TEST_ASSERT_EQUAL_STRING("", seen.instance.c_str());
TEST_ASSERT_EQUAL_STRING("token-of-the-directive", seen.correlationToken.c_str());
TEST_ASSERT_FALSE(seen.reportState);
TEST_ASSERT_EQUAL(1, transport.sent.size());
TEST_ASSERT_EQUAL_STRING("root/ESP-02/alexaResponce", transport.sent[0].topic.c_str());
JsonDocument answer;
TEST_ASSERT_TRUE(deserializeJson(answer, transport.sent[0].json) == DeserializationError::Ok);
TEST_ASSERT_EQUAL_STRING("Response", answer["event"]["header"]["name"] | "");
TEST_ASSERT_EQUAL_STRING("token-of-the-directive", answer["event"]["header"]["correlationToken"] | "");
TEST_ASSERT_EQUAL_STRING("ESP-02", answer["event"]["endpoint"]["endpointId"] | "");
TEST_ASSERT_EQUAL_STRING("ON", answer["context"]["properties"][0]["value"] | "");
TEST_ASSERT_EQUAL_STRING("2026-09-28T13:05:09Z", answer["context"]["properties"][0]["timeOfSample"] | "");
}
void test_directive_with_an_instance_reaches_the_capability_with_that_instance(void)
{
FakeTransport transport;
AlexaDevice blind("Blind", "root", "ESP-01", &transport);
AlexaCapability *lift = blind.addCapability(AlexaInterfaces::RangeController, "Blind.Lift");
AlexaCapability *tilt = blind.addCapability(AlexaInterfaces::RangeController, "Blind.Tilt");
blind.onDirective(onDirective);
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa.RangeController", "SetRangeValue", "Blind.Tilt");
message["payload"]["rangeValue"] = 40;
blind.handleDirective(message);
TEST_ASSERT_EQUAL_PTR(tilt, seen.capability);
TEST_ASSERT_TRUE(AlexaInterfaceType::RANGE_CONTROLLER == seen.type);
TEST_ASSERT_EQUAL_STRING("Blind.Tilt", seen.instance.c_str());
TEST_ASSERT_EQUAL(40, seen.rangeValue);
TEST_ASSERT_EQUAL_PTR(lift, blind.findCapability("Alexa.RangeController", "Blind.Lift"));
TEST_ASSERT_NULL(blind.findCapability("Alexa.RangeController", "Blind.Slat"));
TEST_ASSERT_NULL(blind.findCapability("Alexa.RangeController", ""));
TEST_ASSERT_NULL(blind.findCapability("Alexa.ModeController", "Blind.Lift"));
}
void test_report_state_is_handed_over_without_a_capability_and_answered_with_a_state_report(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.addCapability(AlexaInterfaces::PowerController);
lamp.onDirective(onDirective);
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa", "ReportState");
lamp.handleDirective(message);
TEST_ASSERT_TRUE(seen.reportState);
TEST_ASSERT_NULL(seen.capability);
TEST_ASSERT_EQUAL(1, transport.sent.size());
JsonDocument answer;
TEST_ASSERT_TRUE(deserializeJson(answer, transport.sent[0].json) == DeserializationError::Ok);
TEST_ASSERT_EQUAL_STRING("StateReport", answer["event"]["header"]["name"] | "");
TEST_ASSERT_EQUAL_STRING("OFF", answer["context"]["properties"][0]["value"] | "");
}
void test_one_capability_more_than_a_device_holds_is_refused_with_an_error(void)
{
CapturedLog log;
AlexaDevice device("Panel", "root", "ESP-01");
AlexaCapability *first = nullptr;
for (int i = 0; i < ALEX2ESP_MAX_CAPABILITIES; i++)
{
char instance[16];
snprintf(instance, sizeof(instance), "Panel.%d", i);
AlexaCapability *added = device.addCapability(AlexaInterfaces::ToggleController, instance);
TEST_ASSERT_NOT_NULL(added);
added->addFriendlyName("Switch", "en-US");
if (i == 0)
{
first = added;
}
}
std::string full;
serializeJson(device.getDeviceJSON(), full);
AlexaLog::setOutput(&log);
TEST_ASSERT_NULL(device.addCapability(AlexaInterfaces::PowerController));
TEST_ASSERT_NULL(device.addCapability(AlexaInterfaceType::BRIGHTNESS_CONTROLLER));
char expected[160];
snprintf(expected, sizeof(expected),
"error: ESP-01: capability Alexa.PowerController not added: the device has %d already "
"(ALEX2ESP_MAX_CAPABILITIES)",
ALEX2ESP_MAX_CAPABILITIES);
assertLogged(log, expected);
assertLogged(log, "error: ESP-01: capability Alexa.BrightnessController not added");
// What it has is still found and announced
TEST_ASSERT_EQUAL_PTR(first, device.addCapability(AlexaInterfaces::ToggleController, "Panel.0"));
TEST_ASSERT_NULL(device.findCapability("Alexa.PowerController", ""));
std::string announced;
serializeJson(device.getDeviceJSON(), announced);
TEST_ASSERT_EQUAL_STRING(full.c_str(), announced.c_str());
}
void test_directive_for_an_interface_the_device_lacks_is_answered_with_invalid_directive(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.addCapability(AlexaInterfaces::PowerController);
lamp.onDirective(onDirective);
handlerAnswers = false; // as a handler that knows its own interfaces and leaves the others alone
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa.LockController", "Lock");
lamp.handleDirective(message);
// The handler is asked first: it sees that the device has no such capability
TEST_ASSERT_EQUAL(1, seen.calls);
TEST_ASSERT_NULL(seen.capability);
TEST_ASSERT_TRUE(AlexaInterfaceType::UNKNOWN == seen.type);
TEST_ASSERT_EQUAL(1, transport.sent.size());
assertErrorResponse(transport.sent[0], "root/ESP-01/alexaResponce");
assertLogged(log, "error: ESP-01: Alexa.LockController.Lock refused as INVALID_DIRECTIVE: "
"the device has no such capability");
}
void test_instance_the_device_lacks_is_answered_with_invalid_directive(void)
{
FakeTransport transport;
AlexaDevice blind("Blind", "root", "ESP-01", &transport);
blind.addCapability(AlexaInterfaces::RangeController, "Blind.Lift");
blind.onDirective(onDirective);
handlerAnswers = false;
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa.RangeController", "SetRangeValue", "Blind.Tilt");
blind.handleDirective(message);
TEST_ASSERT_NULL(seen.capability);
TEST_ASSERT_EQUAL(1, transport.sent.size());
assertErrorResponse(transport.sent[0], "root/ESP-01/alexaResponce");
}
void test_answer_of_the_handler_to_an_interface_the_device_lacks_is_the_only_answer(void)
{
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.addCapability(AlexaInterfaces::PowerController);
lamp.onDirective(onDirective);
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa.LockController", "Lock");
lamp.handleDirective(message);
TEST_ASSERT_EQUAL(1, transport.sent.size());
JsonDocument answer;
TEST_ASSERT_TRUE(deserializeJson(answer, transport.sent[0].json) == DeserializationError::Ok);
TEST_ASSERT_EQUAL_STRING("Response", answer["event"]["header"]["name"] | "");
}
void test_device_without_a_handler_answers_with_invalid_directive(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.addCapability(AlexaInterfaces::PowerController);
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa.PowerController", "TurnOn");
lamp.handleDirective(message);
directiveFor(message, "ESP-01", "Alexa", "ReportState");
lamp.handleDirective(message);
TEST_ASSERT_EQUAL(2, transport.sent.size());
assertErrorResponse(transport.sent[0], "root/ESP-01/alexaResponce");
assertErrorResponse(transport.sent[1], "root/ESP-01/alexaResponce");
assertLogged(log, "error: ESP-01: Alexa.PowerController.TurnOn refused as INVALID_DIRECTIVE: "
"the device has no handler, give it one with onDirective()");
}
void test_handler_that_sends_nothing_for_a_capability_of_the_device_is_reported(void)
{
CapturedLog log;
AlexaLog::setOutput(&log);
FakeTransport transport;
AlexaDevice lamp("Lamp", "root", "ESP-01", &transport);
lamp.addCapability(AlexaInterfaces::PowerController);
lamp.onDirective(onDirective);
handlerAnswers = false;
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa.PowerController", "TurnOn");
lamp.handleDirective(message);
TEST_ASSERT_EQUAL(1, seen.calls);
TEST_ASSERT_EQUAL(0, transport.sent.size());
assertLogged(log, "error: ESP-01: the handler sent no answer to Alexa.PowerController.TurnOn");
}
void test_handlers_of_1x_get_report_state_and_the_type_of_every_other_directive(void)
{
FakeTransport transport;
AlexaDevice blind("Blind", "root", "ESP-01", &transport);
blind.addCapability(AlexaInterfaceType::POWER_CONTROLLER);
blind.addCapability(AlexaInterfaceType::TOGGLE_CONTROLLER)->setInstance("ESP-01.Toggle");
blind.registerEvent("ReportState", onReportState);
blind.registerEvent("Event", onEvent);
JsonDocument message;
directiveFor(message, "ESP-01", "Alexa.ToggleController", "TurnOn", "ESP-01.Toggle");
blind.handleDirective(message);
TEST_ASSERT_EQUAL(1, eventCalls);
TEST_ASSERT_EQUAL(0, reportStateCalls);
TEST_ASSERT_TRUE(AlexaInterfaceType::TOGGLE_CONTROLLER == eventType);
directiveFor(message, "ESP-01", "Alexa", "ReportState");
blind.handleDirective(message);
TEST_ASSERT_EQUAL(1, eventCalls);
TEST_ASSERT_EQUAL(1, reportStateCalls);
// With a handler of 2.0 the device calls that one
blind.onDirective(onDirective);
directiveFor(message, "ESP-01", "Alexa.PowerController", "TurnOff");
blind.handleDirective(message);
TEST_ASSERT_EQUAL(1, eventCalls);
TEST_ASSERT_EQUAL(1, seen.calls);
}
int main(int, char **)
{
UNITY_BEGIN();
RUN_TEST(test_devices_are_found_by_their_endpoint_id_in_the_order_they_were_created);
RUN_TEST(test_one_handler_serves_two_devices_and_is_told_which);
RUN_TEST(test_directive_with_an_instance_reaches_the_capability_with_that_instance);
RUN_TEST(test_report_state_is_handed_over_without_a_capability_and_answered_with_a_state_report);
RUN_TEST(test_one_capability_more_than_a_device_holds_is_refused_with_an_error);
RUN_TEST(test_directive_for_an_interface_the_device_lacks_is_answered_with_invalid_directive);
RUN_TEST(test_instance_the_device_lacks_is_answered_with_invalid_directive);
RUN_TEST(test_answer_of_the_handler_to_an_interface_the_device_lacks_is_the_only_answer);
RUN_TEST(test_device_without_a_handler_answers_with_invalid_directive);
RUN_TEST(test_handler_that_sends_nothing_for_a_capability_of_the_device_is_reported);
RUN_TEST(test_handlers_of_1x_get_report_state_and_the_type_of_every_other_directive);
return UNITY_END();
}