test: cover the radio queue rules, shade TX state, rolling codes and web command hold

Move the pieces of the radio TX path that don't need FreeRTOS or NVS out of
Somfy.cpp and Web.cpp so they build in the native test env: the TX job and
event types and job encoding (TxBuffer), the queue rules (TxQueuePolicy.h),
a shade's pending radio jobs (ShadeTxState), the next rolling code
(RollingCode.h) and the web command queue types (WebCommand.h).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-12 20:26:33 +02:00
parent 6a3769406f
commit 1962778912
15 changed files with 912 additions and 186 deletions

View file

@ -0,0 +1,245 @@
#include <unity.h>
#include "ShadeMotion.h"
#define ASSERT_POS(expected, actual) TEST_ASSERT_FLOAT_WITHIN(0.01f, (expected), (actual))
static const int8_t DOWN = 1;
static const int8_t UP = -1;
static const bool IDLE = false;
static const bool MOVING = true;
static somfy_tx_event_t evt(tx_event_t type, uint32_t seq, uint32_t at = 0) {
somfy_tx_event_t e;
e.type = type;
e.seq = seq;
e.at = at;
return e;
}
// A shade whose idle motor was set moving by radio job seq.
static ShadeTxState heldBy(uint32_t seq) {
ShadeTxState tx;
tx.hold.mark(seq, false, 0);
return tx;
}
void setUp() {}
void tearDown() {}
/*********************************************************************
* The job that set the shade moving
********************************************************************/
static void test_held_job_started_starts_the_movement() {
ShadeTxState tx = heldBy(7);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::started, 7, 1234), MOVING);
TEST_ASSERT_TRUE(act.startMovement);
TEST_ASSERT_EQUAL_UINT32(1234, act.startAt);
TEST_ASSERT_FALSE(act.cancelMovement);
TEST_ASSERT_FALSE(tx.hold.held());
}
static void test_held_job_finished_after_start_does_nothing() {
ShadeTxState tx = heldBy(7);
tx.onEvent(evt(tx_event_t::started, 7, 1234), MOVING);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::finished, 7, 2000), MOVING);
TEST_ASSERT_FALSE(act.any());
}
static void test_held_job_dropped_cancels_the_movement() {
ShadeTxState tx = heldBy(7);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 7), MOVING);
TEST_ASSERT_TRUE(act.cancelMovement);
TEST_ASSERT_FALSE(act.startMovement);
TEST_ASSERT_FALSE(tx.hold.held());
}
static void test_held_job_aborted_before_start_cancels_the_movement() {
ShadeTxState tx = heldBy(7);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::aborted, 7), MOVING);
TEST_ASSERT_TRUE(act.cancelMovement);
}
/*********************************************************************
* The favorite (long press My)
********************************************************************/
static void test_favorite_started_waits_for_the_end() {
ShadeTxState tx;
tx.setMySent(9, 40.0f, 10.0f);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::started, 9), IDLE);
TEST_ASSERT_FALSE(act.any());
TEST_ASSERT_EQUAL_UINT32(9, tx.mySeq);
}
static void test_favorite_finished_is_committed() {
ShadeTxState tx;
tx.setMySent(9, 40.0f, 10.0f);
tx.onEvent(evt(tx_event_t::started, 9), IDLE);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::finished, 9), IDLE);
TEST_ASSERT_TRUE(act.commitMy);
TEST_ASSERT_FALSE(act.restoreMy);
TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq);
}
static void test_favorite_aborted_restores_the_previous_one() {
// The motor only stores the favorite when it gets the whole long press.
ShadeTxState tx;
tx.setMySent(9, 40.0f, 10.0f);
tx.onEvent(evt(tx_event_t::started, 9), IDLE);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::aborted, 9), IDLE);
TEST_ASSERT_TRUE(act.restoreMy);
TEST_ASSERT_FALSE(act.commitMy);
ASSERT_POS(40.0f, act.myOldPos);
ASSERT_POS(10.0f, act.myOldTiltPos);
TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq);
}
static void test_favorite_dropped_restores_the_previous_one() {
ShadeTxState tx;
tx.setMySent(9, -1.0f, -1.0f);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 9), IDLE);
TEST_ASSERT_TRUE(act.restoreMy);
ASSERT_POS(-1.0f, act.myOldPos);
ASSERT_POS(-1.0f, act.myOldTiltPos);
}
/*********************************************************************
* The stop sent at the target
********************************************************************/
static void test_stop_dropped_runs_on_to_the_limit() {
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12, 5300), IDLE);
TEST_ASSERT_TRUE(act.runToLimit);
TEST_ASSERT_EQUAL_INT8(DOWN, act.limitDir);
TEST_ASSERT_FALSE(act.limitTilt);
// From when the motor should have stopped, not when the drop was reported.
TEST_ASSERT_EQUAL_UINT32(5000, act.limitSince);
TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq);
}
static void test_tilt_stop_dropped_runs_the_tilt_on() {
ShadeTxState tx;
tx.stopSent(12, 5000, UP, true);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12), IDLE);
TEST_ASSERT_TRUE(act.runToLimit);
TEST_ASSERT_EQUAL_INT8(UP, act.limitDir);
TEST_ASSERT_TRUE(act.limitTilt);
}
static void test_stop_dropped_after_moving_again_does_nothing() {
// Something set the shade moving since, so that command decides where the
// motor goes.
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12), MOVING);
TEST_ASSERT_FALSE(act.any());
TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq);
}
static void test_stop_on_air_does_nothing() {
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::started, 12), IDLE).any());
TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq);
// Nothing is waiting on it any more.
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 12), IDLE).any());
}
static void test_stop_aborted_does_not_run_on() {
// Part of the stop went on air, which is enough for the motor.
ShadeTxState tx;
tx.stopSent(12, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::aborted, 12), IDLE).any());
}
/*********************************************************************
* Edge cases
********************************************************************/
static void test_one_event_for_the_held_job_and_the_favorite() {
// The delayed My that stores the favorite also sets an idle shade moving,
// so the same job is held and awaited as the favorite.
ShadeTxState tx = heldBy(9);
tx.setMySent(9, 40.0f, 10.0f);
ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 9), MOVING);
TEST_ASSERT_TRUE(act.cancelMovement);
TEST_ASSERT_TRUE(act.restoreMy);
TEST_ASSERT_FALSE(tx.hold.held());
TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq);
}
static void test_seq_zero_is_ignored() {
ShadeTxState tx;
tx.setMySent(0, 40.0f, 10.0f);
tx.stopSent(0, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 0), IDLE).any());
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::finished, 0), IDLE).any());
}
static void test_other_jobs_are_ignored() {
ShadeTxState tx = heldBy(7);
tx.setMySent(9, 40.0f, 10.0f);
tx.stopSent(12, 5000, DOWN, false);
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 8), IDLE).any());
TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::finished, 13), IDLE).any());
TEST_ASSERT_EQUAL_UINT32(7, tx.hold.seq);
TEST_ASSERT_EQUAL_UINT32(9, tx.mySeq);
TEST_ASSERT_EQUAL_UINT32(12, tx.stopSeq);
}
/*********************************************************************
* limitTarget
********************************************************************/
static void test_limitTarget() {
ASSERT_POS(100.0f, limitTarget(DOWN));
ASSERT_POS(0.0f, limitTarget(UP));
}
/*********************************************************************
* TxHold::check
********************************************************************/
static void test_check_nothing_held() {
TxHold hold;
TEST_ASSERT_TRUE(hold.check(true, 1000, 30000) == TxHoldCheck::none);
}
static void test_check_idle_shade_drops_the_hold() {
// The command didn't set the shade moving so there is nothing to hold.
TxHold hold;
hold.mark(5, false, 1000);
TEST_ASSERT_TRUE(hold.check(false, 1000, 30000) == TxHoldCheck::none);
TEST_ASSERT_FALSE(hold.held());
}
static void test_check_holds_a_moving_shade() {
TxHold hold;
hold.mark(5, false, 1000);
TEST_ASSERT_TRUE(hold.check(true, 30999, 30000) == TxHoldCheck::holding);
TEST_ASSERT_TRUE(hold.held());
}
static void test_check_expires() {
TxHold hold;
hold.mark(5, false, 1000);
TEST_ASSERT_TRUE(hold.check(true, 31000, 30000) == TxHoldCheck::expired);
TEST_ASSERT_FALSE(hold.held());
TEST_ASSERT_TRUE(hold.check(true, 31001, 30000) == TxHoldCheck::none);
}
static void test_check_across_millis_wrap() {
TxHold hold;
hold.mark(5, false, 0xFFFFF000u);
TEST_ASSERT_TRUE(hold.check(true, 0x00006000u, 30000) == TxHoldCheck::holding);
TEST_ASSERT_TRUE(hold.check(true, 0x00007000u, 30000) == TxHoldCheck::expired);
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_held_job_started_starts_the_movement);
RUN_TEST(test_held_job_finished_after_start_does_nothing);
RUN_TEST(test_held_job_dropped_cancels_the_movement);
RUN_TEST(test_held_job_aborted_before_start_cancels_the_movement);
RUN_TEST(test_favorite_started_waits_for_the_end);
RUN_TEST(test_favorite_finished_is_committed);
RUN_TEST(test_favorite_aborted_restores_the_previous_one);
RUN_TEST(test_favorite_dropped_restores_the_previous_one);
RUN_TEST(test_stop_dropped_runs_on_to_the_limit);
RUN_TEST(test_tilt_stop_dropped_runs_the_tilt_on);
RUN_TEST(test_stop_dropped_after_moving_again_does_nothing);
RUN_TEST(test_stop_on_air_does_nothing);
RUN_TEST(test_stop_aborted_does_not_run_on);
RUN_TEST(test_one_event_for_the_held_job_and_the_favorite);
RUN_TEST(test_seq_zero_is_ignored);
RUN_TEST(test_other_jobs_are_ignored);
RUN_TEST(test_limitTarget);
RUN_TEST(test_check_nothing_held);
RUN_TEST(test_check_idle_shade_drops_the_hold);
RUN_TEST(test_check_holds_a_moving_shade);
RUN_TEST(test_check_expires);
RUN_TEST(test_check_across_millis_wrap);
return UNITY_END();
}