fix: protocol 2 core review findings

From the review of #99:
- A sync could stall when a client took a slot the moment another left:
  loop() used up the new client's sync on the old one. The slots move
  to ProtoSlots, a pure module with native tests, and loop() takes a
  slot's wants only for the client it read (takeWants).
- Deleting a group sends its shades, whose inGroup changed. Deleting a
  shade unlinks it from its groups and sends them, so a new shade given
  its id doesn't join them.
- next and sync with an id get an ok reply, and a message in pieces
  gets one bad request, not one per piece.
- Feeds check for loop() before they write the event buffer, and log
  an event too large, as events do.
- editChanges() (ModelEditor, native tests) says what clients get after
  an edit; the edit result carries the shade a group gained or lost.
  order() takes a sort_list_t.
- The frame writers move to ProtoFrame. Renames: pf to proto_field_t,
  the X-macro letters to TYPE/FIELD/ERR/FEED, proto_request_t to
  proto_message_t, REQ_* to WANTS_*, fmt to eventJson. The event
  bodies share sendItem(), the connection type name lives in
  ESPNetwork, and the loop() task check in SocketEmitter.
- e2e: the heap floor check skips when the low since boot is already
  under it; tests for replies to next and sync and a long message.
- SECURITY_PLAN.md splits step 1 into 1a-1d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-24 13:17:15 +02:00
parent d869bb53d5
commit dc2f1478b6
29 changed files with 1087 additions and 670 deletions

View file

@ -35,6 +35,34 @@ def test_an_event_type_from_a_client_is_unknown(connect, schema):
assert error_name(schema, reply_to(c, msg_id)) == "unknownOp"
def test_next_and_sync_with_an_id_are_answered(connect, schema):
"""A message with an id gets one reply, control messages too."""
c = synced(connect, schema)
msg_id = c.send(schema.type_numbers["next"], with_id=True)
assert reply_to(c, msg_id)["d"].get(schema.keys["ok"]) == 1
msg_id = c.send(schema.type_numbers["sync"], with_id=True)
assert reply_to(c, msg_id)["d"].get(schema.keys["ok"]) == 1
def test_a_long_message_gets_one_reply(connect, schema):
"""A frame bigger than a TCP segment reaches the device in pieces, which it
can't put together yet: still one reply for the whole message. The message
is wrong either way, so a reply is due however it arrives."""
c = synced(connect, schema)
c.send_text('{"v":2,"t":%d,"id":5,"x":"%s"}' % (schema.type_numbers["shadeState"], "a" * 3000))
replies = []
try:
while True:
f = c.recv(timeout=3)
if f["t"] == 2:
replies.append(f)
except TimeoutError:
pass
assert len(replies) == 1, replies
# In pieces it can't be read, so its id isn't known; whole, it is an event.
assert error_name(schema, replies[0]) in ("badRequest", "unknownOp")
def test_subscribing_to_a_feed_is_ok(connect, schema):
c = synced(connect, schema)
for feed in ("frames", "frequencyScan"):

View file

@ -16,13 +16,17 @@ MAX_CLIENTS = 5
@pytest.fixture
def watch_heap(host):
"""Fails the test when the device rebooted or its heap fell under the floor."""
"""Fails the test when the device rebooted or its heap fell under the floor.
The device reports its lowest heap since it booted, so a dip under the
floor only shows while that low is still above it."""
before = heap(host)
if before["min"] < HEAP_FLOOR:
pytest.skip("the heap has been down to %u bytes since boot: reboot the device to check the floor" % before["min"])
yield before
after = heap(host)
assert after["uptime"] > before["uptime"], "the device rebooted"
assert after["min"] >= HEAP_FLOOR or after["min"] == before["min"], (
"the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR))
assert after["min"] >= HEAP_FLOOR, "the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR)
def test_every_client_reconnecting_at_once(connect, schema, watch_heap):

View file

@ -134,6 +134,66 @@ static void test_255_means_no_pin() {
TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::roller, radio_proto::GP_Remote, 255, 26, 255));
}
/*********************************************************************
* What protocol 2 clients are sent after an edit
********************************************************************/
static edit_result_t resultFor(uint8_t id, uint8_t shadeId = 0) {
edit_result_t r;
r.id = id;
r.shadeId = shadeId;
return r;
}
static void test_a_shade_edit_sends_the_whole_shade() {
const model_edit_type_t types[] = {
model_edit_type_t::add_shade, model_edit_type_t::save_shade, model_edit_type_t::set_paired,
model_edit_type_t::link_remote, model_edit_type_t::unlink_remote
};
for(model_edit_type_t t : types) {
const edit_changes_t c = editChanges(t, resultFor(7));
TEST_ASSERT_EQUAL_UINT8(7, c.shadeId);
TEST_ASSERT_EQUAL_UINT8(0, c.groupId);
TEST_ASSERT_EQUAL_UINT8(0, c.roomId);
}
}
static void test_a_group_edit_sends_the_whole_group() {
TEST_ASSERT_EQUAL_UINT8(3, editChanges(model_edit_type_t::add_group, resultFor(3)).groupId);
TEST_ASSERT_EQUAL_UINT8(3, editChanges(model_edit_type_t::save_group, resultFor(3)).groupId);
}
static void test_linking_a_shade_to_a_group_sends_both() {
// The shade's inGroup changes with the group's shades.
for(model_edit_type_t t : {model_edit_type_t::link_to_group, model_edit_type_t::unlink_from_group}) {
const edit_changes_t c = editChanges(t, resultFor(3, 9));
TEST_ASSERT_EQUAL_UINT8(3, c.groupId);
TEST_ASSERT_EQUAL_UINT8(9, c.shadeId);
}
}
static void test_a_room_edit_sends_the_room() {
TEST_ASSERT_EQUAL_UINT8(2, editChanges(model_edit_type_t::add_room, resultFor(2)).roomId);
TEST_ASSERT_EQUAL_UINT8(2, editChanges(model_edit_type_t::save_room, resultFor(2)).roomId);
}
static void test_a_sort_edit_sends_its_list() {
TEST_ASSERT_EQUAL(sort_list_t::rooms, editChanges(model_edit_type_t::room_order, resultFor(0)).order);
TEST_ASSERT_EQUAL(sort_list_t::shades, editChanges(model_edit_type_t::shade_order, resultFor(0)).order);
TEST_ASSERT_EQUAL(sort_list_t::groups, editChanges(model_edit_type_t::group_order, resultFor(0)).order);
TEST_ASSERT_EQUAL(sort_list_t::none, editChanges(model_edit_type_t::save_shade, resultFor(1)).order);
}
static void test_a_repeater_edit_sends_the_repeaters() {
TEST_ASSERT_TRUE(editChanges(model_edit_type_t::link_repeater, resultFor(0)).repeaters);
TEST_ASSERT_TRUE(editChanges(model_edit_type_t::unlink_repeater, resultFor(0)).repeaters);
TEST_ASSERT_FALSE(editChanges(model_edit_type_t::save_room, resultFor(1)).repeaters);
}
static void test_deletes_send_nothing_more() {
// Their removals go out as they happen.
for(model_edit_type_t t : {model_edit_type_t::delete_room, model_edit_type_t::delete_shade, model_edit_type_t::delete_group}) {
const edit_changes_t c = editChanges(t, resultFor(4));
TEST_ASSERT_EQUAL_UINT8(0, c.shadeId);
TEST_ASSERT_EQUAL_UINT8(0, c.groupId);
TEST_ASSERT_EQUAL_UINT8(0, c.roomId);
TEST_ASSERT_EQUAL(sort_list_t::none, c.order);
TEST_ASSERT_FALSE(c.repeaters);
}
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
@ -157,5 +217,12 @@ int main(int argc, char **argv) {
RUN_TEST(test_a_pin_another_shade_uses);
RUN_TEST(test_only_the_pins_a_shade_drives_are_checked);
RUN_TEST(test_255_means_no_pin);
RUN_TEST(test_a_shade_edit_sends_the_whole_shade);
RUN_TEST(test_a_group_edit_sends_the_whole_group);
RUN_TEST(test_linking_a_shade_to_a_group_sends_both);
RUN_TEST(test_a_room_edit_sends_the_room);
RUN_TEST(test_a_sort_edit_sends_its_list);
RUN_TEST(test_a_repeater_edit_sends_the_repeaters);
RUN_TEST(test_deletes_send_nothing_more);
return UNITY_END();
}

View file

@ -4,6 +4,7 @@
#include <vector>
#include <ArduinoJson.h>
#include "ProtoSchema.h"
#include "ProtoFrame.h"
#include "ProtoSync.h"
#include "ProtoParse.h"
@ -21,16 +22,16 @@ static BufferedJsonFormatter fmt;
********************************************************************/
static void test_field_keys_and_names_are_unique() {
std::set<std::string> keys, names;
for(uint8_t i = 0; i < static_cast<uint8_t>(pf::count); i++) {
const proto_field_info_t &f = protoField(static_cast<pf>(i));
for(uint8_t i = 0; i < static_cast<uint8_t>(proto_field_t::count); i++) {
const proto_field_info_t &f = protoField(static_cast<proto_field_t>(i));
TEST_ASSERT_TRUE_MESSAGE(keys.insert(f.key).second, f.key);
TEST_ASSERT_TRUE_MESSAGE(names.insert(f.name).second, f.name);
TEST_ASSERT_TRUE_MESSAGE(strlen(f.key) >= 1 && strlen(f.key) <= 2, f.key);
}
}
static void test_every_field_has_a_known_kind() {
for(uint8_t i = 0; i < static_cast<uint8_t>(pf::count); i++) {
const proto_field_info_t &f = protoField(static_cast<pf>(i));
for(uint8_t i = 0; i < static_cast<uint8_t>(proto_field_t::count); i++) {
const proto_field_info_t &f = protoField(static_cast<proto_field_t>(i));
TEST_ASSERT_NOT_NULL_MESSAGE(strchr("binslo", f.kind), f.name);
}
}
@ -52,12 +53,12 @@ static void test_error_and_feed_numbers_are_unique() {
}
static void test_bootstrap_keys_are_fixed() {
// A client needs these before it has the schema.
TEST_ASSERT_EQUAL_STRING("fw", protoField(pf::fw).key);
TEST_ASSERT_EQUAL_STRING("sh", protoField(pf::schemaHash).key);
TEST_ASSERT_EQUAL_STRING("T", protoField(pf::types).key);
TEST_ASSERT_EQUAL_STRING("F", protoField(pf::fields).key);
TEST_ASSERT_EQUAL_STRING("E", protoField(pf::errors).key);
TEST_ASSERT_EQUAL_STRING("Q", protoField(pf::feeds).key);
TEST_ASSERT_EQUAL_STRING("fw", protoField(proto_field_t::fw).key);
TEST_ASSERT_EQUAL_STRING("sh", protoField(proto_field_t::schemaHash).key);
TEST_ASSERT_EQUAL_STRING("T", protoField(proto_field_t::types).key);
TEST_ASSERT_EQUAL_STRING("F", protoField(proto_field_t::fields).key);
TEST_ASSERT_EQUAL_STRING("E", protoField(proto_field_t::errors).key);
TEST_ASSERT_EQUAL_STRING("Q", protoField(proto_field_t::feeds).key);
}
static void test_schema_hash_is_eight_hex_digits_and_stable() {
const std::string first = protoSchemaHash();
@ -83,22 +84,22 @@ static std::string writeObject(void (*fill)(ProtoWriter &)) {
}
static void test_values_at_their_default_are_left_out() {
TEST_ASSERT_EQUAL_STRING("{}", writeObject([](ProtoWriter &w) {
w.add(pf::position, (int8_t)0);
w.add(pf::myPos, (int8_t)-1);
w.add(pf::repeats, (uint8_t)1);
w.add(pf::paired, false);
w.add(pf::name, "");
w.add(pf::name, (const char *)nullptr);
w.add(proto_field_t::position, (int8_t)0);
w.add(proto_field_t::myPos, (int8_t)-1);
w.add(proto_field_t::repeats, (uint8_t)1);
w.add(proto_field_t::paired, false);
w.add(proto_field_t::name, "");
w.add(proto_field_t::name, (const char *)nullptr);
}).c_str());
}
static void test_values_off_their_default_are_written_by_key() {
TEST_ASSERT_EQUAL_STRING("{\"p\":50,\"m\":0,\"i\":1,\"n\":\"Office\",\"a\":4294967295,\"q\":-5}", writeObject([](ProtoWriter &w) {
w.add(pf::position, (int8_t)50);
w.add(pf::myPos, (int8_t)0);
w.add(pf::paired, true);
w.add(pf::name, "Office");
w.add(pf::remoteAddress, (uint32_t)0xFFFFFFFF);
w.add(pf::tiltPosition, (int8_t)-5);
w.add(proto_field_t::position, (int8_t)50);
w.add(proto_field_t::myPos, (int8_t)0);
w.add(proto_field_t::paired, true);
w.add(proto_field_t::name, "Office");
w.add(proto_field_t::remoteAddress, (uint32_t)0xFFFFFFFF);
w.add(proto_field_t::tiltPosition, (int8_t)-5);
}).c_str());
}
@ -117,6 +118,11 @@ static void test_error_reply_frame() {
// ok is 0, its default, so it is left out.
TEST_ASSERT_EQUAL_STRING("{\"v\":2,\"t\":2,\"id\":17,\"d\":{\"e\":4,\"x\":\"Shade not found\"}}", buff);
}
static void test_ok_reply_frame() {
fmt.setBuffer(buff, sizeof(buff));
writeOkReply(fmt, 9);
TEST_ASSERT_EQUAL_STRING("{\"v\":2,\"t\":2,\"id\":9,\"d\":{\"k\":1}}", buff);
}
/*********************************************************************
* Items
@ -193,8 +199,8 @@ class ArraySource : public SyncSource {
bool shade(uint8_t slot, shade_snapshot_t &out) override { out = shades[slot]; return out.shadeId != 0; }
bool group(uint8_t slot, group_snapshot_t &out) override { out = groups[slot]; return out.groupId != 0; }
bool repeater(uint8_t slot, uint32_t &address) override { address = repeaters[slot]; return address != 0; }
void header(ProtoWriter &w) override { w.add(pf::maxShades, (uint8_t)SOMFY_MAX_SHADES); }
void status(ProtoWriter &w) override { w.add(pf::freeHeap, (uint32_t)80000); }
void header(ProtoWriter &w) override { w.add(proto_field_t::maxShades, (uint8_t)SOMFY_MAX_SHADES); }
void status(ProtoWriter &w) override { w.add(proto_field_t::freeHeap, (uint32_t)80000); }
};
static void fillWidest(ArraySource &src) {
for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) {
@ -236,7 +242,7 @@ static std::vector<JsonDocument> runSync(SyncSource &src, bool withSchema, size_
return frames;
}
// How many items a section has over every frame.
static size_t count(const std::vector<JsonDocument> &frames, pf section) {
static size_t count(const std::vector<JsonDocument> &frames, proto_field_t section) {
size_t n = 0;
for(const JsonDocument &doc : frames) {
JsonVariantConst v = doc["d"][protoField(section).key];
@ -250,16 +256,16 @@ static void test_full_sync_sends_every_item_whole() {
fillWidest(src);
const std::vector<JsonDocument> frames = runSync(src, true, PROTO_MAX_FRAME);
TEST_ASSERT_GREATER_THAN_UINT32(1, frames.size());
TEST_ASSERT_EQUAL_UINT32(protoTypeCount(), count(frames, pf::types));
TEST_ASSERT_EQUAL_UINT32(static_cast<uint8_t>(pf::count), count(frames, pf::fields));
TEST_ASSERT_EQUAL_UINT32(protoErrorCount(), count(frames, pf::errors));
TEST_ASSERT_EQUAL_UINT32(protoFeedCount(), count(frames, pf::feeds));
TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::header));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_ROOMS, count(frames, pf::rooms));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(frames, pf::shades));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(frames, pf::groups));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_REPEATERS, count(frames, pf::repeaters));
TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::status));
TEST_ASSERT_EQUAL_UINT32(protoTypeCount(), count(frames, proto_field_t::types));
TEST_ASSERT_EQUAL_UINT32(static_cast<uint8_t>(proto_field_t::count), count(frames, proto_field_t::fields));
TEST_ASSERT_EQUAL_UINT32(protoErrorCount(), count(frames, proto_field_t::errors));
TEST_ASSERT_EQUAL_UINT32(protoFeedCount(), count(frames, proto_field_t::feeds));
TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::header));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_ROOMS, count(frames, proto_field_t::rooms));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(frames, proto_field_t::shades));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(frames, proto_field_t::groups));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_REPEATERS, count(frames, proto_field_t::repeaters));
TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::status));
// Every shade comes out with all its linked remotes.
for(const JsonDocument &doc : frames)
for(JsonVariantConst s : doc["d"]["S"].as<JsonArrayConst>())
@ -270,8 +276,8 @@ static void test_a_client_with_the_schema_skips_it() {
src = ArraySource();
src.shades[0] = restingShade(1);
const std::vector<JsonDocument> frames = runSync(src, false, PROTO_MAX_FRAME);
TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::types));
TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::fields));
TEST_ASSERT_EQUAL_UINT32(0, count(frames, proto_field_t::types));
TEST_ASSERT_EQUAL_UINT32(0, count(frames, proto_field_t::fields));
TEST_ASSERT_EQUAL_UINT32(1, frames.size());
TEST_ASSERT_EQUAL(1, frames[0]["d"]["S"][0]["s"].as<int>());
}
@ -282,9 +288,9 @@ static void test_empty_slots_are_skipped() {
src.shades[20] = restingShade(21);
src.rooms[3].roomId = 4;
const std::vector<JsonDocument> frames = runSync(src, false, PROTO_MAX_FRAME);
TEST_ASSERT_EQUAL_UINT32(2, count(frames, pf::shades));
TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::rooms));
TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::groups));
TEST_ASSERT_EQUAL_UINT32(2, count(frames, proto_field_t::shades));
TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::rooms));
TEST_ASSERT_EQUAL_UINT32(0, count(frames, proto_field_t::groups));
TEST_ASSERT_TRUE(frames[0]["d"]["X"].isNull());
}
static void test_a_small_frame_takes_more_frames_and_keeps_items_whole() {
@ -293,8 +299,8 @@ static void test_a_small_frame_takes_more_frames_and_keeps_items_whole() {
const std::vector<JsonDocument> big = runSync(src, false, PROTO_MAX_FRAME);
const std::vector<JsonDocument> small = runSync(src, false, 1400);
TEST_ASSERT_GREATER_THAN_UINT32(big.size(), small.size());
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(small, pf::shades));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(small, pf::groups));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(small, proto_field_t::shades));
TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(small, proto_field_t::groups));
}
static void test_an_item_too_big_for_a_frame_is_left_out_and_the_rest_go() {
static ArraySource src;
@ -303,7 +309,7 @@ static void test_an_item_too_big_for_a_frame_is_left_out_and_the_rest_go() {
src.shades[1] = restingShade(2);
// Too small for the widest shade, big enough for a resting one.
const std::vector<JsonDocument> frames = runSync(src, false, 200);
TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::shades));
TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::shades));
}
static void test_the_largest_shade_fits_one_frame() {
// test_full_sync covers it in the sync; this is the shade event on its own.
@ -320,26 +326,26 @@ static void test_the_largest_shade_fits_one_frame() {
/*********************************************************************
* What clients send
********************************************************************/
static proto_error_t parse(const char *text, proto_request_t &req) {
static proto_error_t parse(const char *text, proto_message_t &req) {
JsonDocument doc;
return parseClientFrame(text, strlen(text), doc, req);
}
static void test_next_is_read() {
proto_request_t req;
proto_message_t req;
TEST_ASSERT_EQUAL(proto_error_t::none, parse("{\"v\":2,\"t\":3}", req));
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(proto_type_t::next), req.type);
TEST_ASSERT_EQUAL_UINT32(0, req.id);
}
static void test_subscribe_carries_its_id_and_payload() {
proto_request_t req;
proto_message_t req;
JsonDocument doc;
const char *text = "{\"v\":2,\"t\":6,\"id\":9,\"d\":{\"fe\":1}}";
TEST_ASSERT_EQUAL(proto_error_t::none, parseClientFrame(text, strlen(text), doc, req));
TEST_ASSERT_EQUAL_UINT32(9, req.id);
TEST_ASSERT_EQUAL_INT32(1, protoInt(doc["d"], pf::feed));
TEST_ASSERT_EQUAL_INT32(1, protoInt(doc["d"], proto_field_t::feed));
}
static void test_bad_frames_are_refused() {
proto_request_t req;
proto_message_t req;
TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("not json", req));
TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("[2,3]", req));
TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("{\"v\":1,\"t\":3}", req));
@ -349,7 +355,7 @@ static void test_bad_frames_are_refused() {
TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("{\"v\":2,\"t\":3,\"id\":\"x\"}", req));
}
static void test_types_a_client_may_not_send_are_unknown() {
proto_request_t req;
proto_message_t req;
// A device event, and a number nobody uses.
TEST_ASSERT_EQUAL(proto_error_t::unknownOp, parse("{\"v\":2,\"t\":128,\"id\":5}", req));
TEST_ASSERT_EQUAL_UINT32(5, req.id);
@ -359,12 +365,12 @@ static void test_a_left_out_field_reads_as_its_default() {
JsonDocument doc;
deserializeJson(doc, "{\"p\":40}");
JsonObjectConst d = doc.as<JsonObjectConst>();
TEST_ASSERT_EQUAL_INT32(40, protoInt(d, pf::position));
TEST_ASSERT_EQUAL_INT32(-1, protoInt(d, pf::myPos));
TEST_ASSERT_FALSE(protoBool(d, pf::paired));
TEST_ASSERT_NULL(protoString(d, pf::name));
TEST_ASSERT_TRUE(protoHas(d, pf::position));
TEST_ASSERT_FALSE(protoHas(d, pf::target));
TEST_ASSERT_EQUAL_INT32(40, protoInt(d, proto_field_t::position));
TEST_ASSERT_EQUAL_INT32(-1, protoInt(d, proto_field_t::myPos));
TEST_ASSERT_FALSE(protoBool(d, proto_field_t::paired));
TEST_ASSERT_NULL(protoString(d, proto_field_t::name));
TEST_ASSERT_TRUE(protoHas(d, proto_field_t::position));
TEST_ASSERT_FALSE(protoHas(d, proto_field_t::target));
}
int main(int argc, char **argv) {
@ -382,6 +388,7 @@ int main(int argc, char **argv) {
RUN_TEST(test_values_off_their_default_are_written_by_key);
RUN_TEST(test_hello_frame);
RUN_TEST(test_error_reply_frame);
RUN_TEST(test_ok_reply_frame);
RUN_TEST(test_shade_state_of_a_resting_shade_is_its_id);
RUN_TEST(test_group_lists_shade_ids);
RUN_TEST(test_full_sync_sends_every_item_whole);

View file

@ -0,0 +1,172 @@
#include <unity.h>
#include "ProtoSlots.h"
// Protocol 2's client slots and the pace of their syncs: what async_tcp and
// loop() each do with a slot, in the orders the two tasks can interleave.
static proto_slot_t slots[PROTO_MAX_CLIENTS];
void setUp() {
for(proto_slot_t &s : slots) {
freeSlot(s);
s.loopId = 0;
s.cur = sync_cursor_t();
s.awaitingNext = false;
s.sentAt = 0;
}
}
void tearDown() {}
// A client as onConnect sets it up: a slot, then hello, then the sync.
static proto_slot_t &connect(uint32_t id, bool withSchema = true) {
proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, id, withSchema);
TEST_ASSERT_NOT_NULL(slot);
startSync(*slot);
return *slot;
}
// What sendSyncFrame does with the cursor: on to the end, or one frame in.
static void sendAll(proto_slot_t &slot, uint32_t now) {
slot.cur.section = sync_section_t::done;
syncFrameSent(slot, now);
}
static void sendSome(proto_slot_t &slot, uint32_t now) {
slot.cur.section = sync_section_t::shades;
syncFrameSent(slot, now);
}
/*********************************************************************
* Slots
********************************************************************/
static void test_a_sixth_client_gets_no_slot() {
for(uint32_t id = 1; id <= PROTO_MAX_CLIENTS; id++) connect(id);
TEST_ASSERT_NULL(claimSlot(slots, PROTO_MAX_CLIENTS, 99, true));
}
static void test_a_freed_slot_is_taken_again_clean() {
proto_slot_t &a = connect(1);
a.feeds.fetch_or(0x02);
a.wants.fetch_or(WANTS_NEXT);
freeSlot(a);
TEST_ASSERT_NULL(slotOf(slots, PROTO_MAX_CLIENTS, 1));
proto_slot_t *b = claimSlot(slots, PROTO_MAX_CLIENTS, 2, false);
TEST_ASSERT_EQUAL_PTR(&a, b);
TEST_ASSERT_EQUAL_UINT8(0, b->feeds.load());
TEST_ASSERT_EQUAL_UINT32(0, b->wants.load());
TEST_ASSERT_FALSE(b->withSchema.load());
}
static void test_no_sync_before_hello() {
// claimSlot alone, as onConnect has it before hello has gone out.
proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, 1, true);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(*slot, 0));
startSync(*slot);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(*slot, 0));
}
/*********************************************************************
* The sync's pace
********************************************************************/
static void test_a_new_client_gets_the_schema_unless_it_has_it() {
proto_slot_t &a = connect(1, true);
proto_slot_t &b = connect(2, false);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 0));
TEST_ASSERT_EQUAL(sync_section_t::types, a.cur.section);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(b, 0));
TEST_ASSERT_EQUAL(sync_section_t::header, b.cur.section);
TEST_ASSERT_EQUAL_UINT32(2, b.loopId);
}
static void test_the_next_frame_waits_for_next() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 100);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 200));
a.wants.fetch_or(WANTS_NEXT);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 300));
}
static void test_next_after_the_sync_has_ended_does_nothing() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendAll(a, 100);
a.wants.fetch_or(WANTS_NEXT);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 200));
}
static void test_a_sync_again_starts_over_without_the_schema() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 100);
a.wants.fetch_or(WANTS_SYNC_AGAIN);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 200));
TEST_ASSERT_EQUAL(sync_section_t::header, a.cur.section);
}
static void test_no_next_for_30s_closes_the_client_once() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 1000);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 1000 + PROTO_SYNC_TIMEOUT_MS));
TEST_ASSERT_EQUAL(sync_step_t::timedOut, nextSyncStep(a, 1001 + PROTO_SYNC_TIMEOUT_MS));
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 2000 + PROTO_SYNC_TIMEOUT_MS));
}
static void test_a_finished_sync_never_times_out() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendAll(a, 0);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 10 * PROTO_SYNC_TIMEOUT_MS));
}
/*********************************************************************
* A slot changing hands under loop()
********************************************************************/
static void test_a_client_that_left_mid_sync_is_forgotten() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 0);
freeSlot(a);
TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 1));
TEST_ASSERT_FALSE(a.awaitingNext);
TEST_ASSERT_EQUAL_UINT32(0, a.loopId);
}
static void test_the_next_client_in_the_slot_gets_its_own_sync() {
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
sendSome(a, 0);
freeSlot(a);
connect(2);
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 1));
TEST_ASSERT_EQUAL_UINT32(2, a.loopId);
TEST_ASSERT_EQUAL(sync_section_t::types, a.cur.section);
}
static void test_wants_taken_for_a_client_that_left_stay_for_the_next() {
// loop() read client 1, then 1 left and 2 took the slot before loop() took
// what it wanted: 2's sync must not be used up on 1.
proto_slot_t &a = connect(1);
nextSyncStep(a, 0);
freeSlot(a);
connect(2);
TEST_ASSERT_EQUAL_UINT32(0, takeWants(a, 1));
TEST_ASSERT_EQUAL_UINT32(WANTS_SYNC, a.wants.load());
TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 1));
TEST_ASSERT_EQUAL_UINT32(2, a.loopId);
}
static void test_wants_of_the_client_loop_read_are_taken() {
proto_slot_t &a = connect(1);
TEST_ASSERT_EQUAL_UINT32(WANTS_SYNC, takeWants(a, 1));
TEST_ASSERT_EQUAL_UINT32(0, a.wants.load());
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_a_sixth_client_gets_no_slot);
RUN_TEST(test_a_freed_slot_is_taken_again_clean);
RUN_TEST(test_no_sync_before_hello);
RUN_TEST(test_a_new_client_gets_the_schema_unless_it_has_it);
RUN_TEST(test_the_next_frame_waits_for_next);
RUN_TEST(test_next_after_the_sync_has_ended_does_nothing);
RUN_TEST(test_a_sync_again_starts_over_without_the_schema);
RUN_TEST(test_no_next_for_30s_closes_the_client_once);
RUN_TEST(test_a_finished_sync_never_times_out);
RUN_TEST(test_a_client_that_left_mid_sync_is_forgotten);
RUN_TEST(test_the_next_client_in_the_slot_gets_its_own_sync);
RUN_TEST(test_wants_taken_for_a_client_that_left_stay_for_the_next);
RUN_TEST(test_wants_of_the_client_loop_read_are_taken);
return UNITY_END();
}