fix: edit ops review findings

From the review of 7723711:
- The assembler is locked: a client closed for a full queue is closed
  by the task that was sending, loop(), whose disconnect dropped the
  buffer while async_tcp could be copying into it. A whole message is
  taken out of it, so a disconnect can't free it while it is read.
- loop() expires a message stalled for 10 s, freeing its buffer and
  telling its client, instead of holding it until another client came.
  A binary message is refused before it can touch another's buffer.
- Each edit takes only the fields its route reads; one it would ignore
  is refused by name. linkRemote's rolling code goes in under the
  route's name, so applyModelEdit is unchanged again.
- An edit refused for an id that doesn't exist replies not found
  (edit_result_t::missing). A reply that doesn't fit its buffer goes
  as an internal error, so the op still gets one.
- The TYPE table says what becomes of each op (proto_op_t), which the
  socket switches on. The reply address is one proto_reply_to_t. The
  routes and the ops start and queue an edit the same way
  (newModelEdit, submitModelEdit). The full-list texts and the offered
  remote address rule live in ModelEditor.
- e2e: the sort order test skips a list that it would renumber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-24 18:10:06 +02:00
parent 77237111ea
commit 8fa087aa89
23 changed files with 486 additions and 257 deletions

View file

@ -75,15 +75,27 @@ def test_an_edit_of_a_missing_shade_says_why(connect, schema):
"""Refused by the edit itself on loop(), as its route would be."""
c, shades, _ = synced_model(connect, schema)
reply = op(c, schema, "saveShade", {"shadeId": unused(shades, "shadeId"), "name": "x"})
assert error_name(schema, reply) == "badRequest"
assert error_name(schema, reply) == "notFound"
assert reply["d"][schema.keys["desc"]] == "Shade Id not found."
def test_a_field_an_edit_doesnt_take_is_refused(connect, schema):
c, shades, _ = synced_model(connect, schema)
reply = op(c, schema, "saveShade", {"shadeId": unused(shades, "shadeId"), "paired": 1})
assert error_name(schema, reply) == "badRequest"
assert reply["d"][schema.keys["desc"]] == "paired can't be set by this edit"
def test_a_sort_order_set_to_what_it_is(connect, schema):
c, shades, _ = synced_model(connect, schema)
if not shades:
pytest.skip("the device has no shades")
ids = [s["shadeId"] for s in sorted(shades, key=lambda s: s.get("sortOrder", 0))]
ordered = sorted(shades, key=lambda s: s.get("sortOrder", 0))
# A sort order numbers the shades 0, 1, 2...: only a list already numbered
# so stays as it is.
if [s.get("sortOrder", 0) for s in ordered] != list(range(len(ordered))):
pytest.skip("the shades' sort orders have gaps or repeats, which a sort order would renumber")
ids = [s["shadeId"] for s in ordered]
reply = op(c, schema, "shadeSortOrder", {"ids": ids})
assert is_ok(schema, reply)
order = c.recv_until(lambda f: f["t"] == schema.type_numbers["shadeOrder"])[-1]

View file

@ -50,6 +50,26 @@ static void test_type_names_are_unique() {
std::set<std::string> names;
for(uint8_t i = 0; i < protoTypeCount(); i++) TEST_ASSERT_TRUE_MESSAGE(names.insert(protoTypeAt(i).name).second, protoTypeAt(i).name);
}
static void test_every_op_says_what_becomes_of_it() {
for(uint8_t i = 0; i < protoTypeCount(); i++) {
const proto_named_t &t = protoTypeAt(i);
const bool op = t.number >= 16 && t.number <= 127;
TEST_ASSERT_EQUAL_MESSAGE(op, protoOpKind(t.number) != proto_op_t::none, t.name);
}
TEST_ASSERT_EQUAL(proto_op_t::shadeRequest, protoOpKind(static_cast<uint8_t>(proto_type_t::shadeCommand)));
TEST_ASSERT_EQUAL(proto_op_t::remoteFrame, protoOpKind(static_cast<uint8_t>(proto_type_t::sendRemoteCommand)));
TEST_ASSERT_EQUAL(proto_op_t::edit, protoOpKind(static_cast<uint8_t>(proto_type_t::saveShade)));
TEST_ASSERT_EQUAL(proto_op_t::read, protoOpKind(static_cast<uint8_t>(proto_type_t::groupOptions)));
TEST_ASSERT_EQUAL(proto_op_t::none, protoOpKind(250));
}
static void test_a_field_by_its_key() {
proto_field_t f;
TEST_ASSERT_TRUE(protoFieldOfKey("s", f));
TEST_ASSERT_EQUAL(proto_field_t::shadeId, f);
TEST_ASSERT_TRUE(protoFieldOfKey("vc", f));
TEST_ASSERT_EQUAL(proto_field_t::validation, f);
TEST_ASSERT_FALSE(protoFieldOfKey("zz", f));
}
static void test_error_and_feed_numbers_are_unique() {
std::set<uint8_t> errors, feeds;
for(uint8_t i = 0; i < protoErrorCount(); i++) TEST_ASSERT_TRUE(errors.insert(protoErrorAt(i).number).second);
@ -386,6 +406,8 @@ int main(int argc, char **argv) {
RUN_TEST(test_every_field_has_a_known_kind);
RUN_TEST(test_type_numbers_are_unique_and_in_their_sender_range);
RUN_TEST(test_type_names_are_unique);
RUN_TEST(test_every_op_says_what_becomes_of_it);
RUN_TEST(test_a_field_by_its_key);
RUN_TEST(test_error_and_feed_numbers_are_unique);
RUN_TEST(test_bootstrap_keys_are_fixed);
RUN_TEST(test_schema_hash_is_eight_hex_digits_and_stable);

View file

@ -1,4 +1,5 @@
#include <unity.h>
#include <stdlib.h>
#include <string.h>
#include "ProtoAssembly.h"
@ -18,26 +19,32 @@ static proto_piece_t piece(uint32_t client, const char *text, bool first, bool l
p.len = strlen(text);
return p;
}
// Takes the complete message and checks it.
static void assertTaken(MessageAssembler &a, const char *expected) {
size_t len = 0;
char *message = a.take(len);
TEST_ASSERT_EQUAL_STRING(expected, message);
TEST_ASSERT_EQUAL_UINT32(strlen(expected), len);
free(message);
}
static void test_pieces_make_the_message() {
MessageAssembler a(64);
TEST_ASSERT_EQUAL(assembly_t::more, a.add(piece(1, "{\"v\":2,", true, false), 0));
TEST_ASSERT_EQUAL(assembly_t::more, a.add(piece(1, "\"t\":3", false, false), 0));
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "}", false, true), 0));
TEST_ASSERT_EQUAL_STRING("{\"v\":2,\"t\":3}", a.text());
TEST_ASSERT_EQUAL_UINT32(13, a.length());
a.done();
TEST_ASSERT_FALSE(a.holding());
assertTaken(a, "{\"v\":2,\"t\":3}");
}
static void test_nothing_is_held_between_messages() {
static void test_nothing_is_held_once_the_message_is_taken() {
MessageAssembler a(64);
TEST_ASSERT_FALSE(a.holding());
a.add(piece(1, "ab", true, false), 0);
TEST_ASSERT_TRUE(a.holding());
a.add(piece(1, "c", false, true), 0);
a.done();
assertTaken(a, "abc");
TEST_ASSERT_FALSE(a.holding());
TEST_ASSERT_NULL(a.text());
// A client leaving now can't free what was taken.
a.drop(1);
}
static void test_another_clients_message_waits() {
MessageAssembler a(64);
@ -46,7 +53,7 @@ static void test_another_clients_message_waits() {
TEST_ASSERT_EQUAL(assembly_t::busy, a.add(piece(2, "xy", true, false), 10));
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(2, "z", false, true), 10));
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "c", false, true), 20));
TEST_ASSERT_EQUAL_STRING("abc", a.text());
assertTaken(a, "abc");
}
static void test_too_large_is_said_once() {
MessageAssembler a(4);
@ -60,28 +67,40 @@ static void test_a_binary_message_is_refused_once() {
TEST_ASSERT_EQUAL(assembly_t::notText, a.add(piece(1, "ab", true, false, false), 0));
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(1, "c", false, true, false), 0));
}
static void test_a_stalled_message_gives_the_buffer_up() {
static void test_a_binary_message_leaves_another_clients_alone() {
MessageAssembler a(64);
a.add(piece(1, "ab", true, false), 0);
TEST_ASSERT_EQUAL(assembly_t::busy, a.add(piece(2, "x", true, true), PROTO_ASSEMBLY_TIMEOUT_MS - 1));
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(2, "x", true, true), PROTO_ASSEMBLY_TIMEOUT_MS));
a.done();
// The stalled client is told once, when its next piece comes.
TEST_ASSERT_EQUAL(assembly_t::timedOut, a.add(piece(1, "c", false, false), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(1, "d", false, true), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
TEST_ASSERT_EQUAL(assembly_t::notText, a.add(piece(2, "x", true, true, false), 0));
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "c", false, true), 0));
assertTaken(a, "abc");
}
static void test_a_stalled_message_expires_and_its_client_is_owed_a_reply() {
MessageAssembler a(64);
a.add(piece(1, "ab", true, false), 0);
TEST_ASSERT_EQUAL_UINT32(0, a.expire(PROTO_ASSEMBLY_TIMEOUT_MS - 1));
TEST_ASSERT_TRUE(a.holding());
TEST_ASSERT_EQUAL_UINT32(1, a.expire(PROTO_ASSEMBLY_TIMEOUT_MS));
TEST_ASSERT_FALSE(a.holding());
TEST_ASSERT_EQUAL_UINT32(0, a.expire(2 * PROTO_ASSEMBLY_TIMEOUT_MS));
// Its late pieces were answered by the expiry.
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(1, "c", false, true), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
// And the buffer is free for anyone.
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(2, "xy", true, true), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
assertTaken(a, "xy");
}
static void test_a_client_that_leaves_lets_go() {
MessageAssembler a(64);
a.add(piece(1, "ab", true, false), 0);
a.drop(1);
TEST_ASSERT_FALSE(a.holding());
TEST_ASSERT_EQUAL_UINT32(0, a.expire(PROTO_ASSEMBLY_TIMEOUT_MS));
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(2, "xy", true, true), 1));
assertTaken(a, "xy");
}
static void test_a_message_of_one_piece_is_whole() {
// A whole binary frame, or one piece too large for the rest of the code.
MessageAssembler a(64);
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "{}", true, true), 0));
TEST_ASSERT_EQUAL_STRING("{}", a.text());
assertTaken(a, "{}");
}
int main(int argc, char **argv) {
@ -89,11 +108,12 @@ int main(int argc, char **argv) {
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_pieces_make_the_message);
RUN_TEST(test_nothing_is_held_between_messages);
RUN_TEST(test_nothing_is_held_once_the_message_is_taken);
RUN_TEST(test_another_clients_message_waits);
RUN_TEST(test_too_large_is_said_once);
RUN_TEST(test_a_binary_message_is_refused_once);
RUN_TEST(test_a_stalled_message_gives_the_buffer_up);
RUN_TEST(test_a_binary_message_leaves_another_clients_alone);
RUN_TEST(test_a_stalled_message_expires_and_its_client_is_owed_a_reply);
RUN_TEST(test_a_client_that_leaves_lets_go);
RUN_TEST(test_a_message_of_one_piece_is_whole);
return UNITY_END();

View file

@ -91,6 +91,26 @@ static void test_values_are_checked_against_their_kind() {
static void test_an_unknown_key_is_refused() {
TEST_ASSERT_EQUAL_STRING("Unknown field zz", expand(model_edit_type_t::save_room, R"({"r":1,"zz":5})"));
}
static void test_a_field_the_route_would_ignore_is_refused() {
// In the sync, but not something a save sets.
TEST_ASSERT_EQUAL_STRING("linkedRemotes can't be set by this edit", expand(model_edit_type_t::save_shade, R"({"s":3,"lk":[1]})"));
TEST_ASSERT_EQUAL_STRING("paired can't be set by this edit", expand(model_edit_type_t::save_shade, R"({"s":3,"i":1})"));
TEST_ASSERT_EQUAL_STRING("shadeId can't be set by this edit", expand(model_edit_type_t::add_shade, R"({"s":3})"));
TEST_ASSERT_EQUAL_STRING("upTime can't be set by this edit", expand(model_edit_type_t::save_group, R"({"g":1,"U":5})"));
TEST_ASSERT_EQUAL_STRING("name can't be set by this edit", expand(model_edit_type_t::delete_room, R"({"r":1,"n":"x"})"));
}
static void test_link_remote_takes_the_routes_rolling_code() {
TEST_ASSERT_GOOD(expand(model_edit_type_t::link_remote, R"({"s":3,"a":1193046,"c":12})"));
TEST_ASSERT_EQUAL_STRING(R"({"shadeId":3,"remoteAddress":1193046,"rollingCode":12})", written().c_str());
TEST_ASSERT_EQUAL_STRING("lastRollingCode can't be set by this edit", expand(model_edit_type_t::unlink_remote, R"({"s":3,"a":5,"c":12})"));
}
static void test_a_sort_order_takes_only_ids() {
TEST_ASSERT_EQUAL_STRING("A sort order takes only ids", expand(model_edit_type_t::room_order, R"({"is":[1],"r":2})"));
}
static void test_a_shade_save_takes_what_the_form_sends() {
TEST_ASSERT_GOOD(expand(model_edit_type_t::save_shade,
R"({"s":3,"n":"Office","r":1,"a":5,"st":1,"y":2,"bl":80,"pr":1,"U":1,"D":2,"Y":3,"z":5,"fc":1,"fp":0,"ss":1,"li":0,"sm":1,"rp":2,"gu":4,"gd":5,"gm":6,"gl":1})"));
}
int main(int argc, char **argv) {
(void)argc;
@ -105,5 +125,9 @@ int main(int argc, char **argv) {
RUN_TEST(test_a_sort_order_is_its_list_of_ids);
RUN_TEST(test_values_are_checked_against_their_kind);
RUN_TEST(test_an_unknown_key_is_refused);
RUN_TEST(test_a_field_the_route_would_ignore_is_refused);
RUN_TEST(test_link_remote_takes_the_routes_rolling_code);
RUN_TEST(test_a_sort_order_takes_only_ids);
RUN_TEST(test_a_shade_save_takes_what_the_form_sends);
return UNITY_END();
}