Alex2ESP/test/test_bridge_logic/test_main.cpp
David 6fe8311a6f Queue up to eight directives for loop(); drop the repeat of a directive while it arrives
8ea7778 kept one directive for loop() and recognised the repeat that the broker mirror delivers in two ways: bytes compared with the directive that still waited, and the messageId once loop() had parsed it. A repeat that arrived after its directive had been read therefore took the one place until the next loop(), and a different directive behind it was dropped. Alexa sends a group command as one directive per endpoint, so a board with several endpoints lost directives whenever they arrived faster than loop() ran; 1.1.0 queued five tokens.

AlexaDirectiveBuffer is now a queue. The arriving message is collected in a heap block of its own and hashed (FNV-1a, 64 bit) as its fragments come in. At the last fragment it is one of three things: a repeat, when the hash is among the last 16 that were queued, and then it is freed and never takes a place; a directive, which is queued; or lost, when eight directives (ALEX2ESP_MAX_QUEUED_DIRECTIVES) or 8188 bytes (ALEX2ESP_MAX_QUEUED_BYTES, four times the largest directive) already wait. Whether there is a place is decided at the last fragment, because loop() may have read a directive by then. A message for which there is no memory is still hashed, so its loss is reported only when it is not a repeat. loop() parses one directive per call, in the order of arrival, and frees its block before the handler runs.

The messageId check stays for a directive that comes again with other bytes; it remembers 16 ids instead of 4 and shares the ring with the buffer (AlexaRecentHashes). The two limits are in src/AlexaLimits.h, with #error for values that cannot work. The constructor of the buffer takes the allocator, malloc by default, so the tests can let it fail.

For a sketch: nothing to change. The error line of a directive that finds no place reads "directive of N bytes on <topic> dropped: 8 directives, M bytes, already wait for loop()". The heap holds up to 8188 bytes of waiting directives and one arriving directive of up to 2048, where it held one directive.

Measured with the bridge built for the host against a fake MQTT client (not in the repository), directives of 793 bytes, every message delivered twice, 8ea7778 -> this commit:
  repeat of D1 and a new D2 after D1 was handled   D2 dropped -> D2 handled
  group of 5 in one burst                          1 of 5 handled, 8 error lines -> 5 of 5, none
  group of 8 in one burst                          8 of 8 handled, no error line
  group of 10 in one burst, not mirrored           8 of 10 handled, 2 error lines
  group of 10, a loop() after every fourth message 10 of 10 handled, no error line

Tests: 41 host tests (33 before). New: the order of the queue and the reuse of its places, the ninth directive, a place that becomes free while a directive arrives, the limit in bytes, the largest directive in an empty queue, the repeat of a waiting directive and of one that was read, a group of five with repeats, a repeat when no place is free, a directive that differs in one byte, how long a repeat is remembered, a directive and a repeat without memory, the FNV-1a test vectors. They also pass under -fsanitize=address,undefined. Seven faults planted in a copy of AlexaBridgeLogic.cpp (no repeat check, no limit in bytes, no limit in places, no bounds check, a lost directive remembered, release() that keeps the bytes, last in first out) were each noticed: six by failing tests, the missing bounds check by AddressSanitizer as a heap-buffer-overflow.

Built for d1_mini with empty credentials (PlatformIO 6.2.0, espressif8266 4.2.1), static RAM / flash in bytes, e48f858 -> this commit, no warnings:
  basicLight           34,116 / 336,757 -> 34,444 / 337,145
  lightWithBrightness  34,232 / 340,517 -> 34,560 / 340,921
  lightWithColorTemp   34,380 / 341,177 -> 34,708 / 341,581
  tempSensor           34,024 / 335,457 -> 34,352 / 335,845
  blindControl         34,256 / 338,925 -> 34,584 / 339,313
The 328 bytes of RAM are the two rings of 16 hashes (256) and the eight places of the queue.

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

856 lines
33 KiB
C++

// Host tests of the bridge logic (src/AlexaBridgeLogic.cpp): what the MQTT callback decides about the fragments a
// broker delivers, how directives wait for loop(), which of them are repeats, what may be published, topics and
// time stamps.
// pio test -e native
#include <unity.h>
#include <ArduinoJson.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <string>
#include "AlexaBridgeLogic.h"
#include "AlexaLimits.h"
typedef AlexaDirectiveBuffer::Result Result;
#define ASSERT_RESULT(expected, actual) TEST_ASSERT_EQUAL_INT(static_cast<int>(expected), static_cast<int>(actual))
// A directive as Alex2MQTT publishes it on <root>/<endpointId>/alexaDirective
static std::string directive(const char *name, const char *messageId,
const std::string &correlationToken = "AAAAAAAAAQBnlqNbYnB0dHNmYW5zbGF0ZQ==")
{
return std::string("{\"header\":{\"namespace\":\"Alexa.PowerController\",\"name\":\"") + name +
"\",\"payloadVersion\":\"3\",\"messageId\":\"" + messageId +
"\",\"correlationToken\":\"" + correlationToken + "\"},"
"\"endpoint\":{\"endpointId\":\"ESP-01\",\"cookie\":{}},\"payload\":{}}";
}
static const char ID_1[] = "1bd5d003-31b9-476f-ad03-71d471922820";
static const char ID_2[] = "7c0e1f6a-52d4-4b8e-9a3c-0d9f4e2b6a11";
// The limits the bridge passes to its buffer by default
static const size_t LARGEST = 2047;
static const size_t MOST_BYTES = 4 * 2047;
static const size_t PLACES = AlexaDirectiveBuffer::CAPACITY;
// The n-th directive of a series: each has a messageId of its own, as each directive of Alexa has
static std::string numbered(unsigned n)
{
char messageId[37];
snprintf(messageId, sizeof(messageId), "00000000-0000-4000-8000-%012u", n);
return directive(n % 2 == 0 ? "TurnOn" : "TurnOff", messageId);
}
// Hands the message over as the MQTT client does: pieces of fragmentSize bytes with their offset and the total.
// Returns the result of the last piece; every piece before it has to be INCOMPLETE.
static Result deliver(AlexaDirectiveBuffer &buffer, const std::string &message, size_t fragmentSize)
{
Result result = Result::INCOMPLETE;
for (size_t index = 0; index < message.size(); index += fragmentSize)
{
ASSERT_RESULT(Result::INCOMPLETE, result);
size_t length = message.size() - index < fragmentSize ? message.size() - index : fragmentSize;
result = buffer.append(message.data() + index, length, index, message.size());
}
return result;
}
static void assertWaiting(const AlexaDirectiveBuffer &buffer, const std::string &message)
{
TEST_ASSERT_TRUE(buffer.ready());
TEST_ASSERT_EQUAL_UINT(message.size(), buffer.length());
TEST_ASSERT_EQUAL_STRING(message.c_str(), buffer.data()); // also proves the terminating NUL
}
void setUp() {}
void tearDown() {}
// --- reassembly ---
void test_directive_in_one_piece_is_complete()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
ASSERT_RESULT(Result::COMPLETE, buffer.append(turnOn.data(), turnOn.size(), 0, turnOn.size()));
assertWaiting(buffer, turnOn);
buffer.release();
TEST_ASSERT_FALSE(buffer.ready());
TEST_ASSERT_NULL(buffer.data());
TEST_ASSERT_EQUAL_UINT(0, buffer.length());
}
void test_directive_in_fragments_is_reassembled_and_parses()
{
std::string turnOn = directive("TurnOn", ID_1);
// Every fragment size from one byte per fragment to the message in two halves
for (size_t fragmentSize = 1; fragmentSize < turnOn.size(); fragmentSize++)
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, fragmentSize));
assertWaiting(buffer, turnOn);
}
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
deliver(buffer, turnOn, 100);
JsonDocument parsed;
TEST_ASSERT_TRUE(deserializeJson(parsed, buffer.data(), buffer.length()) == DeserializationError::Ok);
TEST_ASSERT_EQUAL_STRING("TurnOn", parsed["header"]["name"]);
TEST_ASSERT_EQUAL_STRING("ESP-01", parsed["endpoint"]["endpointId"]);
}
void test_nothing_waits_before_the_last_fragment()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(turnOn.data(), 50, 0, turnOn.size()));
TEST_ASSERT_FALSE(buffer.ready());
TEST_ASSERT_NULL(buffer.data());
}
// --- limits ---
void test_directive_at_the_limit_is_accepted()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string atLimit(2047, 'x');
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, atLimit, 536));
assertWaiting(buffer, atLimit);
}
void test_directive_over_the_limit_is_refused_once()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string tooLarge(2048, 'x');
ASSERT_RESULT(Result::TOO_LARGE, buffer.append(tooLarge.data(), 536, 0, tooLarge.size()));
// Its other fragments still arrive: dropped without another report
ASSERT_RESULT(Result::IGNORED, buffer.append(tooLarge.data() + 536, 536, 536, tooLarge.size()));
ASSERT_RESULT(Result::IGNORED, buffer.append(tooLarge.data() + 1072, 976, 1072, tooLarge.size()));
TEST_ASSERT_FALSE(buffer.ready());
}
void test_refused_directive_does_not_block_the_next_one()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string tooLarge(5000, 'x');
std::string turnOn = directive("TurnOn", ID_1);
ASSERT_RESULT(Result::TOO_LARGE, buffer.append(tooLarge.data(), 1460, 0, tooLarge.size()));
ASSERT_RESULT(Result::IGNORED, buffer.append(tooLarge.data() + 1460, 1460, 1460, tooLarge.size()));
// The connection drops before the rest of it arrives; the next message is a normal directive
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, 100));
assertWaiting(buffer, turnOn);
}
void test_empty_message_is_reported()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
// AsyncMqttClient hands a message without a payload over as (nullptr, 0, 0, 0)
ASSERT_RESULT(Result::EMPTY, buffer.append(nullptr, 0, 0, 0));
TEST_ASSERT_FALSE(buffer.ready());
}
// --- directives wait for loop() ---
void test_directives_wait_in_the_order_they_arrived()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
size_t bytes = 0;
for (unsigned n = 0; n < PLACES; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(n), 100));
bytes += numbered(n).size();
TEST_ASSERT_EQUAL_UINT(n + 1, buffer.waitingDirectives());
TEST_ASSERT_EQUAL_UINT(bytes, buffer.waitingBytes());
}
for (unsigned n = 0; n < PLACES; n++)
{
assertWaiting(buffer, numbered(n));
buffer.release();
}
TEST_ASSERT_FALSE(buffer.ready());
TEST_ASSERT_EQUAL_UINT(0, buffer.waitingDirectives());
TEST_ASSERT_EQUAL_UINT(0, buffer.waitingBytes());
// The places are a ring: they are used again, in the same order
for (unsigned round = 1; round <= 3; round++)
{
for (unsigned n = 0; n < PLACES - 1; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(100 * round + n), 100));
}
for (unsigned n = 0; n < PLACES - 1; n++)
{
assertWaiting(buffer, numbered(100 * round + n));
buffer.release();
}
}
TEST_ASSERT_FALSE(buffer.ready());
}
void test_directive_beyond_the_places_is_dropped_and_the_others_kept()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
for (unsigned n = 0; n < PLACES; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(n), 100));
}
// The loss is reported once, by the last fragment
ASSERT_RESULT(Result::QUEUE_FULL, deliver(buffer, numbered(PLACES), 100));
TEST_ASSERT_EQUAL_UINT(PLACES, buffer.waitingDirectives());
assertWaiting(buffer, numbered(0));
// A directive that was dropped is not taken for a repeat when it comes again and a place is free
buffer.release();
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(PLACES), 100));
for (unsigned n = 1; n <= PLACES; n++)
{
assertWaiting(buffer, numbered(n));
buffer.release();
}
TEST_ASSERT_FALSE(buffer.ready());
}
void test_place_that_becomes_free_while_a_directive_arrives_is_used()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
for (unsigned n = 0; n < PLACES; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(n), 100));
}
std::string late = numbered(PLACES);
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(late.data(), 100, 0, late.size()));
buffer.release(); // loop() ran between two fragments
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(late.data() + 100, 100, 100, late.size()));
ASSERT_RESULT(Result::COMPLETE, buffer.append(late.data() + 200, late.size() - 200, 200, late.size()));
TEST_ASSERT_EQUAL_UINT(PLACES, buffer.waitingDirectives());
}
void test_waiting_directives_are_limited_in_bytes()
{
AlexaDirectiveBuffer buffer(LARGEST, 3000);
std::string first(1500, 'a');
std::string second(1500, 'b');
std::string third(1, 'c');
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, first, 536));
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, second, 536)); // 3000 bytes wait: at the limit
ASSERT_RESULT(Result::QUEUE_FULL, deliver(buffer, third, 536));
TEST_ASSERT_EQUAL_UINT(2, buffer.waitingDirectives());
TEST_ASSERT_EQUAL_UINT(3000, buffer.waitingBytes());
buffer.release();
TEST_ASSERT_EQUAL_UINT(1500, buffer.waitingBytes());
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, third, 536));
assertWaiting(buffer, second);
}
void test_empty_queue_takes_the_largest_directive()
{
// A limit for the waiting bytes under the limit for one directive is raised to it
AlexaDirectiveBuffer buffer(LARGEST, 100);
std::string largest(LARGEST, 'x');
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, largest, 536));
assertWaiting(buffer, largest);
ASSERT_RESULT(Result::QUEUE_FULL, deliver(buffer, std::string("y"), 536));
}
// --- the repeat a mirroring broker delivers ---
void test_repeat_of_a_waiting_directive_is_discarded()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, 100));
ASSERT_RESULT(Result::DUPLICATE, buffer.append(turnOn.data(), turnOn.size(), 0, turnOn.size()));
ASSERT_RESULT(Result::DUPLICATE, deliver(buffer, turnOn, 64)); // in other fragments than the first time
TEST_ASSERT_EQUAL_UINT(1, buffer.waitingDirectives());
assertWaiting(buffer, turnOn);
}
void test_repeat_of_a_directive_that_was_read_does_not_hold_up_the_next()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
std::string turnOff = directive("TurnOff", ID_2);
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, 100));
buffer.release(); // loop() has handled it
// Its repeat and the next directive arrive together
ASSERT_RESULT(Result::DUPLICATE, deliver(buffer, turnOn, 100));
TEST_ASSERT_FALSE(buffer.ready());
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOff, 100));
assertWaiting(buffer, turnOff);
}
void test_group_of_five_with_repeats_in_one_burst()
{
TEST_ASSERT_TRUE(PLACES >= 5);
// Every directive followed by its repeat
AlexaDirectiveBuffer interleaved(LARGEST, MOST_BYTES);
for (unsigned n = 0; n < 5; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(interleaved, numbered(n), 536));
ASSERT_RESULT(Result::DUPLICATE, deliver(interleaved, numbered(n), 536));
}
TEST_ASSERT_EQUAL_UINT(5, interleaved.waitingDirectives());
for (unsigned n = 0; n < 5; n++)
{
assertWaiting(interleaved, numbered(n));
interleaved.release();
}
// The five directives, then the five repeats
AlexaDirectiveBuffer trailing(LARGEST, MOST_BYTES);
for (unsigned n = 0; n < 5; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(trailing, numbered(n), 536));
}
for (unsigned n = 0; n < 5; n++)
{
ASSERT_RESULT(Result::DUPLICATE, deliver(trailing, numbered(n), 536));
}
TEST_ASSERT_EQUAL_UINT(5, trailing.waitingDirectives());
}
void test_repeat_is_recognised_when_no_place_is_free()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
for (unsigned n = 0; n < PLACES; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(n), 100));
}
// Not a loss, so not QUEUE_FULL
ASSERT_RESULT(Result::DUPLICATE, deliver(buffer, numbered(0), 100));
ASSERT_RESULT(Result::DUPLICATE, deliver(buffer, numbered(PLACES - 1), 100));
TEST_ASSERT_EQUAL_UINT(PLACES, buffer.waitingDirectives());
}
void test_directive_that_differs_in_one_byte_is_not_a_repeat()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string first = directive("TurnOn", ID_1);
std::string second = first;
second[second.size() - 10] = '#'; // the same length, the same up to its last fragment
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, first, 100));
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, second, 100));
assertWaiting(buffer, first);
buffer.release();
assertWaiting(buffer, second);
}
void test_repeat_is_forgotten_after_as_many_directives_as_are_remembered()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
const unsigned remembered = AlexaRecentHashes::CAPACITY;
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(0), 100));
buffer.release();
for (unsigned n = 1; n < remembered; n++)
{
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(n), 100));
buffer.release();
}
ASSERT_RESULT(Result::DUPLICATE, deliver(buffer, numbered(0), 100)); // the oldest that is remembered
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(remembered), 100)); // takes its place
buffer.release();
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, numbered(0), 100));
}
// --- no memory ---
static bool memoryLeft = true;
static void *scarceMemory(size_t size)
{
return memoryLeft ? malloc(size) : nullptr;
}
void test_directive_without_memory_is_reported_by_its_last_fragment()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES, scarceMemory);
std::string turnOn = directive("TurnOn", ID_1);
memoryLeft = false;
ASSERT_RESULT(Result::NO_MEMORY, deliver(buffer, turnOn, 100));
TEST_ASSERT_FALSE(buffer.ready());
// It was not remembered: when it comes again and there is memory, it is handled
memoryLeft = true;
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, 100));
assertWaiting(buffer, turnOn);
}
void test_repeat_without_memory_is_still_a_repeat()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES, scarceMemory);
std::string turnOn = directive("TurnOn", ID_1);
memoryLeft = true;
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, 100));
memoryLeft = false;
ASSERT_RESULT(Result::DUPLICATE, deliver(buffer, turnOn, 100));
memoryLeft = true;
assertWaiting(buffer, turnOn);
}
// --- fragments that do not fit ---
void test_fragment_with_a_gap_drops_the_message()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(turnOn.data(), 100, 0, turnOn.size()));
ASSERT_RESULT(Result::OUT_OF_ORDER, buffer.append(turnOn.data() + 150, 50, 150, turnOn.size()));
ASSERT_RESULT(Result::IGNORED, buffer.append(turnOn.data() + 200, turnOn.size() - 200, 200, turnOn.size()));
TEST_ASSERT_FALSE(buffer.ready());
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, 100));
assertWaiting(buffer, turnOn);
}
void test_fragment_beyond_the_total_is_refused()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string message(300, 'x');
// The block holds 200 + 1 bytes; a fragment that would end at 250 must not be copied
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(message.data(), 100, 0, 200));
ASSERT_RESULT(Result::OUT_OF_ORDER, buffer.append(message.data() + 100, 150, 100, 200));
TEST_ASSERT_FALSE(buffer.ready());
// A first fragment longer than its own total
ASSERT_RESULT(Result::OUT_OF_ORDER, buffer.append(message.data(), 300, 0, 200));
TEST_ASSERT_FALSE(buffer.ready());
}
void test_fragment_with_another_total_is_refused()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string message(300, 'x');
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(message.data(), 100, 0, 200));
ASSERT_RESULT(Result::OUT_OF_ORDER, buffer.append(message.data() + 100, 100, 100, 300));
TEST_ASSERT_FALSE(buffer.ready());
}
void test_fragment_without_a_start_is_refused_once()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
ASSERT_RESULT(Result::OUT_OF_ORDER, buffer.append(turnOn.data() + 100, 100, 100, turnOn.size()));
ASSERT_RESULT(Result::IGNORED, buffer.append(turnOn.data() + 200, turnOn.size() - 200, 200, turnOn.size()));
TEST_ASSERT_FALSE(buffer.ready());
}
void test_new_message_replaces_one_that_never_completed()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
std::string turnOff = directive("TurnOff", ID_2);
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(turnOn.data(), 100, 0, turnOn.size()));
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOff, 100));
TEST_ASSERT_EQUAL_UINT(1, buffer.waitingDirectives());
assertWaiting(buffer, turnOff);
}
void test_lost_connection_drops_the_arriving_message_only()
{
AlexaDirectiveBuffer buffer(LARGEST, MOST_BYTES);
std::string turnOn = directive("TurnOn", ID_1);
std::string turnOff = directive("TurnOff", ID_2);
// Nothing waits, a message is arriving
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(turnOn.data(), 100, 0, turnOn.size()));
buffer.cancelArrival();
ASSERT_RESULT(Result::OUT_OF_ORDER, buffer.append(turnOn.data() + 100, 100, 100, turnOn.size()));
TEST_ASSERT_FALSE(buffer.ready());
// A directive waits, another one is arriving
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOff, 100));
ASSERT_RESULT(Result::INCOMPLETE, buffer.append(turnOn.data(), 100, 0, turnOn.size()));
buffer.cancelArrival();
TEST_ASSERT_EQUAL_UINT(1, buffer.waitingDirectives());
assertWaiting(buffer, turnOff);
// What arrived of the message that was cut off does not make the whole message a repeat
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, turnOn, 100));
}
// --- repeated messageIds ---
void test_repeated_message_id_is_recognised()
{
AlexaRecentIds recent;
TEST_ASSERT_FALSE(recent.seenBefore(ID_1));
TEST_ASSERT_TRUE(recent.seenBefore(ID_1));
TEST_ASSERT_FALSE(recent.seenBefore(ID_2));
TEST_ASSERT_TRUE(recent.seenBefore(ID_1));
TEST_ASSERT_TRUE(recent.seenBefore(ID_2));
}
void test_only_the_last_ids_are_remembered()
{
AlexaRecentIds recent;
const unsigned remembered = AlexaRecentIds::CAPACITY;
char id[16];
for (unsigned n = 0; n <= remembered; n++)
{
snprintf(id, sizeof(id), "id-%u", n);
TEST_ASSERT_FALSE(recent.seenBefore(id));
}
// The last one took the place of id-0
for (unsigned n = 1; n <= remembered; n++)
{
snprintf(id, sizeof(id), "id-%u", n);
TEST_ASSERT_TRUE(recent.seenBefore(id));
}
TEST_ASSERT_FALSE(recent.seenBefore("id-0"));
}
void test_recognising_a_repeat_does_not_use_a_place()
{
AlexaRecentIds recent;
const unsigned remembered = AlexaRecentIds::CAPACITY;
char id[16];
TEST_ASSERT_FALSE(recent.seenBefore("id-0"));
for (int i = 0; i < 100; i++)
{
TEST_ASSERT_TRUE(recent.seenBefore("id-0"));
}
for (unsigned n = 1; n < remembered; n++)
{
snprintf(id, sizeof(id), "id-%u", n);
TEST_ASSERT_FALSE(recent.seenBefore(id));
}
TEST_ASSERT_TRUE(recent.seenBefore("id-0"));
}
void test_hash_is_fnv_1a_64()
{
// Test vectors of the FNV reference code
TEST_ASSERT_EQUAL_HEX64(0xcbf29ce484222325ULL, AlexaBridgeLogic::hashBytes("", 0));
TEST_ASSERT_EQUAL_HEX64(0xaf63dc4c8601ec8cULL, AlexaBridgeLogic::hashBytes("a", 1));
TEST_ASSERT_EQUAL_HEX64(0x85944171f73967e8ULL, AlexaBridgeLogic::hashBytes("foobar", 6));
// In pieces, as the fragments of a message are hashed
uint64_t hash = AlexaBridgeLogic::hashBytes("foo", 3);
TEST_ASSERT_EQUAL_HEX64(0x85944171f73967e8ULL, AlexaBridgeLogic::hashBytes("bar", 3, hash));
}
void test_directive_without_a_message_id_is_never_a_repeat()
{
AlexaRecentIds recent;
TEST_ASSERT_FALSE(recent.seenBefore(""));
TEST_ASSERT_FALSE(recent.seenBefore(""));
TEST_ASSERT_FALSE(recent.seenBefore(nullptr));
TEST_ASSERT_FALSE(recent.seenBefore(nullptr));
}
// --- what may be published ---
void test_message_within_the_limit_is_measured()
{
JsonDocument doc;
doc["event"]["header"]["name"] = "Response";
size_t length = 0;
ASSERT_RESULT(AlexaSendResult::OK, AlexaBridgeLogic::checkMessage(doc, 2047, &length));
TEST_ASSERT_EQUAL_UINT(strlen("{\"event\":{\"header\":{\"name\":\"Response\"}}}"), length);
}
void test_message_at_the_limit_is_accepted_and_one_byte_more_is_refused()
{
// {"v":"xxx...x"} is 8 bytes and the text
JsonDocument doc;
size_t length = 0;
doc["v"] = std::string(2047 - 8, 'x');
ASSERT_RESULT(AlexaSendResult::OK, AlexaBridgeLogic::checkMessage(doc, 2047, &length));
TEST_ASSERT_EQUAL_UINT(2047, length);
doc["v"] = std::string(2048 - 8, 'x');
ASSERT_RESULT(AlexaSendResult::TOO_LARGE, AlexaBridgeLogic::checkMessage(doc, 2047, &length));
TEST_ASSERT_EQUAL_UINT(2048, length);
}
void test_empty_document_is_not_published()
{
JsonDocument doc;
size_t length = 99;
ASSERT_RESULT(AlexaSendResult::EMPTY, AlexaBridgeLogic::checkMessage(doc, 2047, &length));
TEST_ASSERT_EQUAL_UINT(0, length);
}
// An allocator that runs dry, as the heap of the board does
struct ScarceAllocator : ArduinoJson::Allocator
{
size_t left;
explicit ScarceAllocator(size_t bytes) : left(bytes) {}
void *allocate(size_t size) override
{
if (size > left)
{
return nullptr;
}
left -= size;
return malloc(size);
}
void deallocate(void *pointer) override { free(pointer); }
void *reallocate(void *pointer, size_t size) override
{
if (size > left)
{
return nullptr;
}
left -= size;
return realloc(pointer, size);
}
};
void test_document_that_ran_out_of_memory_is_not_published()
{
ScarceAllocator allocator(8192);
JsonDocument doc(&allocator);
for (int i = 0; i < 200 && !doc.overflowed(); i++)
{
doc["context"]["properties"][i]["value"] = std::string(100, 'x');
}
TEST_ASSERT_TRUE(doc.overflowed());
size_t length = 99;
ASSERT_RESULT(AlexaSendResult::TOO_LARGE, AlexaBridgeLogic::checkMessage(doc, 1000000, &length));
TEST_ASSERT_EQUAL_UINT(0, length); // 0 tells the two reasons for TOO_LARGE apart
}
// --- the limit of a directive and the limit of its answer ---
// One property of a report as AlexaStatusMessage::AddProperty writes it
static void addProperty(JsonArray properties, const char *interfaceName, const char *name, const char *value)
{
JsonObject property = properties.add<JsonObject>();
property["namespace"] = interfaceName;
property["name"] = name;
property["value"] = value;
property["timeOfSample"] = "2026-09-28T13:05:09Z";
property["uncertaintyInMilliseconds"] = 0;
}
// The Response of a lamp as AlexaStatusMessage builds it (src/AlexaStatusMessage.cpp): the correlationToken of the
// directive, the health of the endpoint and the power state
static void buildResponse(JsonDocument &answer, const JsonDocument &received)
{
JsonObject header = answer["event"]["header"].to<JsonObject>();
header["namespace"] = "Alexa";
header["name"] = "Response";
header["payloadVersion"] = "3";
header["messageId"] = "OQpZQ2l8Pr2f9kkS8g6ffwpx7bJgJARngGUEQ"; // 37 characters, as generateMessageId() returns
header["correlationToken"] = received["header"]["correlationToken"];
answer["event"]["endpoint"]["endpointId"] = received["endpoint"]["endpointId"];
answer["event"]["payload"].to<JsonObject>();
JsonArray properties = answer["context"]["properties"].to<JsonArray>();
JsonObject health = properties.add<JsonObject>();
health["namespace"] = "Alexa.EndpointHealth";
health["name"] = "connectivity";
health["value"]["value"] = "OK";
health["timeOfSample"] = "2026-09-28T13:05:09Z";
health["uncertaintyInMilliseconds"] = 0;
addProperty(properties, "Alexa.PowerController", "powerState", "ON");
}
void test_largest_directive_can_be_answered()
{
// The correlationToken is what makes a directive large, and the answer repeats it
std::string withoutToken = directive("TurnOn", ID_1, "");
std::string largest = directive("TurnOn", ID_1, std::string(ALEX2ESP_MAX_DIRECTIVE - withoutToken.size(), 'T'));
TEST_ASSERT_EQUAL_UINT(ALEX2ESP_MAX_DIRECTIVE, largest.size());
AlexaDirectiveBuffer buffer(ALEX2ESP_MAX_DIRECTIVE, ALEX2ESP_MAX_QUEUED_BYTES);
ASSERT_RESULT(Result::COMPLETE, deliver(buffer, largest, 536));
JsonDocument received;
TEST_ASSERT_TRUE(deserializeJson(received, buffer.data(), buffer.length()) == DeserializationError::Ok);
buffer.release();
JsonDocument answer;
buildResponse(answer, received);
size_t length = 0;
ASSERT_RESULT(AlexaSendResult::OK, AlexaBridgeLogic::checkMessage(answer, ALEX2ESP_MAX_MESSAGE, &length));
// The answer is the larger of the two: a limit for messages as low as the limit for directives refuses it,
// after the sketch has switched the lamp
TEST_ASSERT_GREATER_THAN_UINT(ALEX2ESP_MAX_DIRECTIVE, length);
ASSERT_RESULT(AlexaSendResult::TOO_LARGE, AlexaBridgeLogic::checkMessage(answer, ALEX2ESP_MAX_DIRECTIVE, &length));
// The default leaves room for six properties
JsonArray properties = answer["context"]["properties"];
addProperty(properties, "Alexa.BrightnessController", "brightness", "100");
addProperty(properties, "Alexa.ColorTemperatureController", "colorTemperatureInKelvin", "2700");
addProperty(properties, "Alexa.ToggleController", "toggleState", "ON");
addProperty(properties, "Alexa.ToggleController", "toggleState", "OFF");
TEST_ASSERT_EQUAL_UINT(6, properties.size());
ASSERT_RESULT(AlexaSendResult::OK, AlexaBridgeLogic::checkMessage(answer, ALEX2ESP_MAX_MESSAGE, &length));
}
// --- topics ---
void test_endpoint_is_taken_from_the_directive_topic()
{
size_t length = 0;
const char *topic = "AEXAMPLEROOT/ESP-01/alexaDirective";
const char *endpointId = AlexaBridgeLogic::directiveEndpoint(topic, "AEXAMPLEROOT", &length);
TEST_ASSERT_NOT_NULL(endpointId);
TEST_ASSERT_EQUAL_UINT(6, length);
TEST_ASSERT_EQUAL_STRING_LEN("ESP-01", endpointId, length);
TEST_ASSERT_EQUAL_PTR(topic + strlen("AEXAMPLEROOT/"), endpointId);
// An empty root topic (begin() with "") still has the separator
TEST_ASSERT_NOT_NULL(AlexaBridgeLogic::directiveEndpoint("/ESP-01/alexaDirective", "", &length));
TEST_ASSERT_EQUAL_UINT(6, length);
}
void test_other_topics_are_not_directive_topics()
{
size_t length = 0;
// the token topic of the 1.x HTTP fallback, published next to every directive
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/ESP-01/alexaDirective_e", "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/discover", "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/ESP-01/alexaResponce", "root", &length));
// another root, also one that only starts like ours
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("other/ESP-01/alexaDirective", "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("rootless/ESP-01/alexaDirective", "root", &length));
// no endpoint id, more than one level
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root//alexaDirective", "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/alexaDirective", "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/a/b/alexaDirective", "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("", "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint(nullptr, "root", &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/ESP-01/alexaDirective", nullptr, &length));
TEST_ASSERT_NULL(AlexaBridgeLogic::directiveEndpoint("root/ESP-01/alexaDirective", "root", nullptr));
}
// --- time ---
void test_timestamp_is_iso_8601_in_utc()
{
char stamp[ALEXA_TIMESTAMP_SIZE];
TEST_ASSERT_TRUE(AlexaBridgeLogic::formatTimestamp(0, stamp, sizeof(stamp)));
TEST_ASSERT_EQUAL_STRING("1970-01-01T00:00:00Z", stamp);
TEST_ASSERT_TRUE(AlexaBridgeLogic::formatTimestamp(1709210096, stamp, sizeof(stamp)));
TEST_ASSERT_EQUAL_STRING("2024-02-29T12:34:56Z", stamp); // a leap day
TEST_ASSERT_TRUE(AlexaBridgeLogic::formatTimestamp(1790600709, stamp, sizeof(stamp)));
TEST_ASSERT_EQUAL_STRING("2026-09-28T13:05:09Z", stamp);
TEST_ASSERT_TRUE(AlexaBridgeLogic::formatTimestamp(1798761599, stamp, sizeof(stamp)));
TEST_ASSERT_EQUAL_STRING("2026-12-31T23:59:59Z", stamp);
}
void test_timestamp_needs_a_buffer_of_its_size()
{
char small[ALEXA_TIMESTAMP_SIZE - 1];
memset(small, 'x', sizeof(small));
TEST_ASSERT_FALSE(AlexaBridgeLogic::formatTimestamp(1790600709, small, sizeof(small)));
TEST_ASSERT_EQUAL_STRING("", small);
TEST_ASSERT_FALSE(AlexaBridgeLogic::formatTimestamp(1790600709, nullptr, ALEXA_TIMESTAMP_SIZE));
// The year 10000 does not fit the format
char stamp[ALEXA_TIMESTAMP_SIZE + 4];
TEST_ASSERT_FALSE(AlexaBridgeLogic::formatTimestamp(253402300800LL, stamp, sizeof(stamp)));
TEST_ASSERT_EQUAL_STRING("", stamp);
}
void test_clock_counts_as_set_from_2024()
{
TEST_ASSERT_FALSE(AlexaBridgeLogic::clockIsSet(0));
TEST_ASSERT_FALSE(AlexaBridgeLogic::clockIsSet(5)); // seconds since boot, before SNTP has answered
TEST_ASSERT_FALSE(AlexaBridgeLogic::clockIsSet(1704067199)); // 2023-12-31T23:59:59Z
TEST_ASSERT_TRUE(AlexaBridgeLogic::clockIsSet(1704067200)); // 2024-01-01T00:00:00Z
TEST_ASSERT_TRUE(AlexaBridgeLogic::clockIsSet(1790600709));
}
int main(int, char **)
{
UNITY_BEGIN();
RUN_TEST(test_directive_in_one_piece_is_complete);
RUN_TEST(test_directive_in_fragments_is_reassembled_and_parses);
RUN_TEST(test_nothing_waits_before_the_last_fragment);
RUN_TEST(test_directive_at_the_limit_is_accepted);
RUN_TEST(test_directive_over_the_limit_is_refused_once);
RUN_TEST(test_refused_directive_does_not_block_the_next_one);
RUN_TEST(test_empty_message_is_reported);
RUN_TEST(test_directives_wait_in_the_order_they_arrived);
RUN_TEST(test_directive_beyond_the_places_is_dropped_and_the_others_kept);
RUN_TEST(test_place_that_becomes_free_while_a_directive_arrives_is_used);
RUN_TEST(test_waiting_directives_are_limited_in_bytes);
RUN_TEST(test_empty_queue_takes_the_largest_directive);
RUN_TEST(test_repeat_of_a_waiting_directive_is_discarded);
RUN_TEST(test_repeat_of_a_directive_that_was_read_does_not_hold_up_the_next);
RUN_TEST(test_group_of_five_with_repeats_in_one_burst);
RUN_TEST(test_repeat_is_recognised_when_no_place_is_free);
RUN_TEST(test_directive_that_differs_in_one_byte_is_not_a_repeat);
RUN_TEST(test_repeat_is_forgotten_after_as_many_directives_as_are_remembered);
RUN_TEST(test_directive_without_memory_is_reported_by_its_last_fragment);
RUN_TEST(test_repeat_without_memory_is_still_a_repeat);
RUN_TEST(test_fragment_with_a_gap_drops_the_message);
RUN_TEST(test_fragment_beyond_the_total_is_refused);
RUN_TEST(test_fragment_with_another_total_is_refused);
RUN_TEST(test_fragment_without_a_start_is_refused_once);
RUN_TEST(test_new_message_replaces_one_that_never_completed);
RUN_TEST(test_lost_connection_drops_the_arriving_message_only);
RUN_TEST(test_repeated_message_id_is_recognised);
RUN_TEST(test_only_the_last_ids_are_remembered);
RUN_TEST(test_recognising_a_repeat_does_not_use_a_place);
RUN_TEST(test_hash_is_fnv_1a_64);
RUN_TEST(test_directive_without_a_message_id_is_never_a_repeat);
RUN_TEST(test_message_within_the_limit_is_measured);
RUN_TEST(test_message_at_the_limit_is_accepted_and_one_byte_more_is_refused);
RUN_TEST(test_empty_document_is_not_published);
RUN_TEST(test_document_that_ran_out_of_memory_is_not_published);
RUN_TEST(test_largest_directive_can_be_answered);
RUN_TEST(test_endpoint_is_taken_from_the_directive_topic);
RUN_TEST(test_other_topics_are_not_directive_topics);
RUN_TEST(test_timestamp_is_iso_8601_in_utc);
RUN_TEST(test_timestamp_needs_a_buffer_of_its_size);
RUN_TEST(test_clock_counts_as_set_from_2024);
return UNITY_END();
}