Files
secs-gem/tests/test_messages.cpp
T
raphael 547fd2116b
tests / build-and-test (push) Failing after 43s
Close COMPLIANCE.md gap: S9 error stream
Adds S9F1, F3, F5, F7, F9, F11, F13 to the message catalog and wires
the two emission paths that the Connection layer can drive without help
from the Router or the application: S9F7 on a body-decode failure and
S9F9 on a T3 transaction-timer timeout.

Catalog (data/messages.yaml -> generated messages.hpp)

  All six MHEAD-carrying messages (F1/F3/F5/F7/F9/F11) use the same
  shape — a single <B 10> body with the offending 10-byte HSMS header.
  S9F13 (conversation timeout) carries <L,2 <A MEXP> <A EDID>>.

Connection-side emissions (src/hsms/connection.cpp)

  emit_s9(function, mhead)   New private helper.  Builds a 9/function/W=0
                              data message whose body is <B 10> with the
                              MHEAD bytes, allocates a fresh sys_bytes,
                              and queues it onto the write path.  No
                              reply is tracked.

  S9F7 on body decode        handle_data wraps Message::from_body in a
                              try/catch.  Previously any decode error
                              closed the connection; now it emits S9F7
                              with the offending header and continues
                              reading.  Reply-side decode failure also
                              emits S9F7 and surfaces the new
                              Error::IllegalData to the waiting
                              ReplyHandler (rather than making the
                              caller wait out T3).

  S9F9 on T3 timeout         The send_request T3 callback rebuilds the
                              original outgoing MHEAD from
                              (device_id, expected_stream,
                              expected_function-1, sys, W=1) and emits
                              S9F9 before invoking the callback with
                              Error::Timeout (unchanged).

What's intentionally not yet wired (logged in COMPLIANCE.md)

  - S9F3 / S9F5 — "unknown stream / function".  These need to live in
    the Router's fallback path, which would require either the Router
    knowing about a Connection-shaped sender or the Connection's
    message wrapper learning which streams the Router has handlers
    for.  Deferred — today the fallback returns SxF0 only.
  - S9F11 — "Data Too Long".  Currently we close on oversized frames;
    we'd need to also build a synthetic 10-byte MHEAD substitute (the
    real header isn't yet available at the point of detection) and
    flush it through close_after_flush.

Tests + docs

  tests/test_messages.cpp  Round-trip every S9F* using a representative
                           10-byte MHEAD literal; check S9F13 carries
                           MEXP + EDID.  +2 cases / +37 assertions.

  COMPLIANCE.md            Error Messages row moved from "no S9 stream"
                           to a detailed status describing what's
                           emitted vs catalog-only.  Coverage matrix
                           expanded per-message (F1/F7/F9/F13 ;
                           F3/F5/F11 🟡 catalog-only).

Build/demo unaffected: 75 cases / 420 assertions pass; the happy-path
demo never trips a decode error or T3, so the S9 path isn't exercised
end-to-end (but unit tests prove the wire shape).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 19:14:41 +02:00

263 lines
9.1 KiB
C++

#include <doctest/doctest.h>
#include "secsgem/gem/messages.hpp"
using namespace secsgem::gem;
TEST_CASE("S1F3 round-trip preserves SVID list") {
auto m = s1f3_selected_status_request({1, 2, 99});
CHECK(m.stream == 1);
CHECK(m.function == 3);
CHECK(m.reply_expected);
auto parsed = parse_s1f3(m);
REQUIRE(parsed.has_value());
CHECK(*parsed == std::vector<uint32_t>{1, 2, 99});
}
TEST_CASE("S1F4 substitutes empty list for unknown SVIDs") {
std::vector<std::optional<s2::Item>> values = {s2::Item::u4(uint32_t{42}), std::nullopt,
s2::Item::ascii("ok")};
auto m = s1f4_selected_status_data(values);
REQUIRE(m.body.has_value());
const auto& children = m.body->as_list();
REQUIRE(children.size() == 3);
CHECK(children[0] == s2::Item::u4(uint32_t{42}));
CHECK(children[1] == s2::Item::list({}));
CHECK(children[2] == s2::Item::ascii("ok"));
}
TEST_CASE("S1F12 round-trip preserves SVID, name, units") {
std::vector<StatusName> svs = {{1, "Clock", "sec"}, {2, "Power", "W"}};
auto m = s1f12_status_namelist_data(svs);
auto parsed = parse_s1f12(m);
REQUIRE(parsed.has_value());
REQUIRE(parsed->size() == 2);
CHECK((*parsed)[0].id == 1);
CHECK((*parsed)[0].name == "Clock");
CHECK((*parsed)[0].units == "sec");
CHECK((*parsed)[1].id == 2);
CHECK((*parsed)[1].name == "Power");
}
TEST_CASE("S2F15 round-trip preserves ECID-value pairs") {
std::vector<EcSet> sets = {
{10, s2::Item::u4(uint32_t{50})},
{11, s2::Item::ascii("YYYYMMDDhhmmsscc")},
};
auto m = s2f15_ec_send(sets);
auto parsed = parse_s2f15(m);
REQUIRE(parsed.has_value());
REQUIRE(parsed->size() == 2);
CHECK((*parsed)[0].ecid == 10);
CHECK((*parsed)[0].value == s2::Item::u4(uint32_t{50}));
CHECK((*parsed)[1].ecid == 11);
CHECK((*parsed)[1].value == s2::Item::ascii("YYYYMMDDhhmmsscc"));
}
TEST_CASE("S2F16 EAC ack round-trip") {
auto m = s2f16_ec_ack(EquipmentAck::Denied_OutOfRange);
auto byte = ack_byte(m);
REQUIRE(byte.has_value());
CHECK(*byte == static_cast<uint8_t>(EquipmentAck::Denied_OutOfRange));
}
TEST_CASE("S2F18 carries 16-char time string") {
auto m = s2f18_date_time_data("2026052812345678");
auto t = parse_s2f18(m);
REQUIRE(t.has_value());
CHECK(*t == "2026052812345678");
}
TEST_CASE("S2F41 round-trip with parameters") {
std::vector<CommandParameter> params = {
{"LOTID", s2::Item::ascii("LOT-42")},
{"PPID", s2::Item::ascii("RECIPE-A")},
};
auto m = s2f41_host_command("START", params);
auto parsed = parse_s2f41(m);
REQUIRE(parsed.has_value());
CHECK(parsed->rcmd == "START");
REQUIRE(parsed->params.size() == 2);
CHECK(parsed->params[0].name == "LOTID");
CHECK(parsed->params[0].value == s2::Item::ascii("LOT-42"));
CHECK(parsed->params[1].name == "PPID");
}
TEST_CASE("S2F42 round-trip with HCACK and CPACKs") {
auto m = s2f42_host_command_ack(HostCmdAck::ParameterInvalid,
{{"LOTID", 0}, {"PPID", 3}});
auto parsed = parse_s2f42(m);
REQUIRE(parsed.has_value());
CHECK(parsed->hcack == HostCmdAck::ParameterInvalid);
REQUIRE(parsed->cpacks.size() == 2);
CHECK(parsed->cpacks[0].name == "LOTID");
CHECK(parsed->cpacks[0].code == 0);
CHECK(parsed->cpacks[1].name == "PPID");
CHECK(parsed->cpacks[1].code == 3);
}
TEST_CASE("S2F42 no-params variant") {
auto m = s2f42_host_command_ack(HostCmdAck::Accept, {});
auto parsed = parse_s2f42(m);
REQUIRE(parsed.has_value());
CHECK(parsed->hcack == HostCmdAck::Accept);
CHECK(parsed->cpacks.empty());
}
TEST_CASE("S10F3 terminal display round-trip") {
auto m = s10f3_terminal_display_single(1, "ALARM: chiller temperature high");
auto parsed = parse_s10f3(m);
REQUIRE(parsed.has_value());
CHECK(parsed->tid == 1);
CHECK(parsed->text == "ALARM: chiller temperature high");
}
// ---- S9 error-stream round-trips ----------------------------------------
TEST_CASE("S9 MHEAD-carrying messages round-trip") {
// A representative 10-byte HSMS header (data message, S2F33 W, sys=42).
const std::string mhead(
"\x12\x34" // session id
"\x82" // byte2: W=1, stream=2
"\x21" // byte3: function=33
"\x00" // ptype
"\x00" // stype=Data
"\x00\x00\x00\x2A", // system bytes = 42
10);
auto check = [&](const s2::Message& m, std::optional<std::string> (*p)(const s2::Message&)) {
CHECK(m.stream == 9);
CHECK_FALSE(m.reply_expected);
auto out = p(m);
REQUIRE(out.has_value());
CHECK(*out == mhead);
};
check(s9f1_unrecognized_device_id(mhead), parse_s9f1);
check(s9f3_unrecognized_stream_type(mhead), parse_s9f3);
check(s9f5_unrecognized_function_type(mhead), parse_s9f5);
check(s9f7_illegal_data(mhead), parse_s9f7);
check(s9f9_transaction_timer_timeout(mhead), parse_s9f9);
check(s9f11_data_too_long(mhead), parse_s9f11);
}
TEST_CASE("S9F13 conversation timeout carries MEXP + EDID") {
auto m = s9f13_conversation_timeout("S2F33W", "EQUIP-01");
CHECK(m.stream == 9);
CHECK(m.function == 13);
auto parsed = parse_s9f13(m);
REQUIRE(parsed.has_value());
CHECK(parsed->mexp == "S2F33W");
CHECK(parsed->edid == "EQUIP-01");
}
// ---- Extended GEM message round-trips ------------------------------------
TEST_CASE("S2F33 define-report round-trip") {
auto m = s2f33_define_report(7, {{1000, {1, 2, 3}}, {1001, {4}}});
auto parsed = parse_s2f33(m);
REQUIRE(parsed.has_value());
CHECK(parsed->dataid == 7);
REQUIRE(parsed->reports.size() == 2);
CHECK(parsed->reports[0].rptid == 1000);
CHECK(parsed->reports[0].vids == std::vector<uint32_t>{1, 2, 3});
CHECK(parsed->reports[1].rptid == 1001);
CHECK(parsed->reports[1].vids == std::vector<uint32_t>{4});
}
TEST_CASE("S2F35 link-event round-trip") {
auto m = s2f35_link_event_report(0, {{100, {1000, 1001}}, {200, {1001}}});
auto parsed = parse_s2f35(m);
REQUIRE(parsed.has_value());
CHECK(parsed->dataid == 0);
REQUIRE(parsed->links.size() == 2);
CHECK(parsed->links[0].ceid == 100);
CHECK(parsed->links[0].rptids == std::vector<uint32_t>{1000, 1001});
}
TEST_CASE("S2F37 enable-event round-trip") {
auto enable = s2f37_enable_event(true, {100, 200});
auto disable = s2f37_enable_event(false, {});
auto pe = parse_s2f37(enable);
auto pd = parse_s2f37(disable);
REQUIRE(pe.has_value());
REQUIRE(pd.has_value());
CHECK(pe->enable);
CHECK(pe->ceids == std::vector<uint32_t>{100, 200});
CHECK_FALSE(pd->enable);
CHECK(pd->ceids.empty());
}
TEST_CASE("S5F1 alarm-report round-trip") {
auto m = s5f1_alarm_report(0x84, 7, "Chiller temp high");
auto parsed = parse_s5f1(m);
REQUIRE(parsed.has_value());
CHECK(parsed->alid == 7);
CHECK(parsed->altx == "Chiller temp high");
CHECK(parsed->alcd == 0x84);
CHECK((parsed->alcd & 0x80) != 0); // set bit
CHECK((parsed->alcd & 0x7F) == 4); // category
}
TEST_CASE("S5F3 enable/disable alarm send round-trip") {
auto on = s5f3_enable_alarm(kAlarmEnableByte, 42);
auto off = s5f3_enable_alarm(kAlarmDisableByte, 42);
auto pon = parse_s5f3(on);
auto poff = parse_s5f3(off);
REQUIRE(pon.has_value());
REQUIRE(poff.has_value());
CHECK((pon->aled & 0x80) != 0);
CHECK((poff->aled & 0x80) == 0);
CHECK(pon->alid == 42);
}
TEST_CASE("S5F6 alarm list builder marks active alarms with bit 7") {
std::vector<Alarm> alarms = {{1, "Door", 1}, {2, "Heater", 4}};
auto m = s5f6_list_alarms_data(alarms, [](uint32_t id) { return id == 2; });
REQUIRE(m.body.has_value());
REQUIRE(m.body->is_list());
REQUIRE(m.body->as_list().size() == 2);
const auto& row0 = m.body->as_list()[0].as_list();
const auto& row1 = m.body->as_list()[1].as_list();
CHECK(row0[0].as_bytes()[0] == 0x01); // door inactive: cat 1, bit 7 clear
CHECK(row1[0].as_bytes()[0] == 0x84); // heater active: cat 4, bit 7 set
}
TEST_CASE("S6F11 event-report round-trip preserves DATAID, CEID, reports") {
std::vector<ReportData> reports = {
{1000, {s2::Item::ascii("OnlineRemote")}},
{1001, {s2::Item::u4(uint32_t{42}), s2::Item::ascii("hello")}},
};
auto m = s6f11_event_report(7, 100, reports);
auto parsed = parse_s6f11(m);
REQUIRE(parsed.has_value());
CHECK(parsed->dataid == 7);
CHECK(parsed->ceid == 100);
REQUIRE(parsed->reports.size() == 2);
CHECK(parsed->reports[0].rptid == 1000);
CHECK(parsed->reports[0].values[0] == s2::Item::ascii("OnlineRemote"));
CHECK(parsed->reports[1].rptid == 1001);
CHECK(parsed->reports[1].values[0] == s2::Item::u4(uint32_t{42}));
}
TEST_CASE("S7F3 process-program send round-trip") {
auto m = s7f3_process_program_send("RECIPE-A", "step1\nstep2\n");
auto parsed = parse_s7f3(m);
REQUIRE(parsed.has_value());
CHECK(parsed->ppid == "RECIPE-A");
CHECK(parsed->ppbody == "step1\nstep2\n");
}
TEST_CASE("S7F19 / S7F20 EPPD list") {
auto req = s7f19_current_eppd_request();
CHECK(req.stream == 7);
CHECK(req.function == 19);
CHECK(req.reply_expected);
CHECK_FALSE(req.body.has_value());
auto data = s7f20_current_eppd_data({"RECIPE-A", "RECIPE-B"});
auto parsed = parse_s7f20(data);
REQUIRE(parsed.has_value());
CHECK(*parsed == std::vector<std::string>{"RECIPE-A", "RECIPE-B"});
}