From b0a4c331cff046823eec04f06469b90537e94194 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Wed, 10 Jun 2026 19:26:28 +0200 Subject: [PATCH] test(gem): table-driven conformance sweep over the default handler set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One ordered in-process scenario drives 53 of the 56 registered handlers through Router::dispatch — S1 identification/comms/control, S2 ECs/clock/ event-config/commands/trace/limits/spool, S5 alarms+exceptions, S6 reports, S7 recipes, S10 terminal, S14/S16 E39+E40/E94 jobs, S3 carriers — asserting every reply is the paired (stream, function+1) with a body, plus targeted state checks (OnlineRemote after S1F17, PJ exists after S16F11, HostOffline after S1F15) and the Router's SxF0 abort fallback for unregistered W=1 primaries. Same flow secs_conformance runs over a live socket, but cheap enough for every build; closes the '56 handlers, 4 direct tests' gap from the design review. Also seeds message-level golden frames: S1F13's body pinned to bytes hand-computed from the E5 encoding rules — an external check on message composition, not our codec validating itself (TODO: S5F1, composed S6F11). Suite: 466 cases / 3052 assertions (+236), all green. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 1 + docs/DAEMON_ROADMAP.md | 9 +- tests/test_handler_conformance.cpp | 189 +++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 tests/test_handler_conformance.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 594d1b3..a02ca80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -218,6 +218,7 @@ add_executable(secsgem_tests tests/test_data_model.cpp tests/test_runtime.cpp tests/test_default_handlers.cpp + tests/test_handler_conformance.cpp tests/test_name_index.cpp tests/test_messages.cpp tests/test_loader.cpp diff --git a/docs/DAEMON_ROADMAP.md b/docs/DAEMON_ROADMAP.md index 84ccbd5..48466a4 100644 --- a/docs/DAEMON_ROADMAP.md +++ b/docs/DAEMON_ROADMAP.md @@ -102,9 +102,12 @@ debts tax every later phase, and the most valuable tests aren't automated. localhost, no docker-in-docker). ⬜ Verify the lanes on the first push. 3. ✅ **Fix `CompleteCommand` proto comment** — it described the rejected blocking model; now states the HCACK-4 contract. -4. ⬜ **Table-driven handler conformance test** ((request, expected-reply-shape) - pairs through `router.dispatch` for broad coverage of the 56 handlers) + - message-level golden wire frames (codec KATs exist; message-level don't). +4. 🚧 **Table-driven handler conformance test** — ✅ + `tests/test_handler_conformance.cpp`: one ordered scenario drives 53 of the + 56 handlers through `router.dispatch` in-process (236 assertions), asserting + paired replies, control-state landings, and the SxF0 abort fallback. + Message-level golden frames: seeded with a hand-computed (E5-rules, not + codec-derived) S1F13 pin — ⬜ extend to S5F1 + composed S6F11 (TODO in file). 5. ⬜ **Decompose `register_default_handlers` per GEM capability** (it is a relocated main(), not a designed component) and replace magic constants (SVIDs 1/2 `refresh()`, CEIDs 400/401) with YAML role bindings diff --git a/tests/test_handler_conformance.cpp b/tests/test_handler_conformance.cpp new file mode 100644 index 0000000..11ff9db --- /dev/null +++ b/tests/test_handler_conformance.cpp @@ -0,0 +1,189 @@ +// Table-driven conformance sweep over the default GEM handler set. +// +// The live demo and the external interop harnesses cover the happy path with +// weak assertions ("client exits 0"); this file drives EVERY registered +// (stream, function) through Router::dispatch in-process and asserts the +// reply envelope (same stream, function+1, body present). Ordered as one +// scenario so stateful handlers (event-report config, recipes, PJ/CJ, +// carriers) see the prerequisites they need — the same flow secs_conformance +// runs over a live socket, but cheap enough to run on every build. + +#include + +#include +#include +#include +#include + +#include "secsgem/gem/default_handlers.hpp" +#include "secsgem/gem/messages.hpp" +#include "secsgem/gem/runtime.hpp" +#include "secsgem/secs2/codec.hpp" +#include "secsgem/secs2/item.hpp" + +using namespace secsgem; +namespace gem = secsgem::gem; +namespace s2 = secsgem::secs2; + +#ifndef SECSGEM_DATA_DIR +#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt" +#endif + +namespace { + +gem::EquipmentRuntime::Config test_config() { + gem::EquipmentRuntime::Config c; + c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml"; + c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml"; + c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml"; + c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml"; + c.port = 0; + return c; +} + +} // namespace + +TEST_CASE("conformance sweep: every default handler answers with the paired reply") { + gem::EquipmentRuntime rt(test_config()); + gem::register_default_handlers(rt); + + std::set> covered; + + // Dispatch `req`, assert the reply is (same stream, function+1) with a + // body, record coverage, hand back the reply for targeted extra checks. + auto expect = [&](const char* label, s2::Message req) -> s2::Message { + INFO(label); + auto reply = rt.router().dispatch(req); + REQUIRE_MESSAGE(reply.has_value(), label << ": no reply"); + CHECK_MESSAGE(reply->stream == req.stream, label); + CHECK_MESSAGE(reply->function == req.function + 1, label); + CHECK_MESSAGE(reply->body.has_value(), label << ": reply has no body"); + covered.insert({req.stream, req.function}); + return *reply; + }; + + // ---- S1: identification / comms / control state ------------------------- + expect("S1F1 Are You There", gem::s1f1_are_you_there()); + expect("S1F13 EstablishComms", gem::s1f13_establish_comms("HOST", "1.0")); + expect("S1F17 Request ONLINE", gem::s1f17_request_online()); + CHECK(rt.control_state() == gem::ControlState::OnlineRemote); + expect("S1F3 Status (all)", gem::s1f3_selected_status_request({})); + expect("S1F11 SV namelist", gem::s1f11_status_namelist_request({})); + expect("S1F19 GEM compliance", gem::s1f19_get_gem_compliance_request()); + expect("S1F21 DV namelist", gem::s1f21_data_variable_namelist_request({})); + expect("S1F23 CE namelist", gem::s1f23_collection_event_namelist_request({})); + + // ---- S2: ECs / clock / event-report config / commands / limits ---------- + expect("S2F13 EC request", gem::s2f13_ec_request({})); + expect("S2F15 EC send", gem::s2f15_ec_send({{10, s2::Item::u4(uint32_t{1})}})); + expect("S2F17 Date-time req", gem::s2f17_date_time_request()); + expect("S2F29 EC namelist", gem::s2f29_ec_namelist_request({})); + expect("S2F31 Date-time set", gem::s2f31_date_time_set("2026061012000000")); + expect("S2F33 DefineReport", gem::s2f33_define_report(1, {{1, {2}}})); + expect("S2F35 LinkEvent", gem::s2f35_link_event_report(1, {{300, {1}}})); + expect("S2F37 EnableEvent", gem::s2f37_enable_event(true, {300})); + expect("S2F41 HostCommand", gem::s2f41_host_command("START", {})); + expect("S2F21 RemoteCommand", gem::s2f21_remote_command("STOP")); + expect("S2F49 Enhanced cmd", gem::s2f49_enhanced_host_command(0u, "EQUIPMENT", "NOP", {})); + expect("S2F23 Trace init", gem::s2f23_trace_initialize_send(8001, "000001", 0, 1, {1})); + expect("S2F45 Define limits", gem::s2f45_define_variable_limits( + 7, {{100, {{0, s2::Item::u4(uint32_t{1000}), + s2::Item::u4(uint32_t{0})}}}})); + expect("S2F47 Limits request", gem::s2f47_variable_limit_attribute_request({})); + expect("S2F43 Reset spooling", gem::s2f43_reset_spooling({5, 6})); + + // ---- S5: alarms + exceptions --------------------------------------------- + expect("S5F3 Enable alarm", gem::s5f3_enable_alarm(gem::kAlarmEnableByte, 1)); + expect("S5F5 List alarms", gem::s5f5_list_alarms_request({})); + expect("S5F7 Enabled alarms", gem::s5f7_list_enabled_alarms_request()); + expect("S5F13 Exc recover", gem::s5f13_exception_recover_request(999, "NOP")); + expect("S5F17 Exc rec abort", gem::s5f17_exception_recover_abort_request(999)); + + // ---- S6: event reports / spool ------------------------------------------- + expect("S6F5 Multi-block inq", gem::s6f5_multi_block_inquire(42, 2048)); + expect("S6F15 Event rpt req", gem::s6f15_event_report_request(300)); + expect("S6F19 Individual rpt", gem::s6f19_individual_report_request(1)); + expect("S6F21 Annotated rpt", gem::s6f21_annotated_report_request(1)); + expect("S6F23 Spool data req", gem::s6f23_request_spool_data(gem::SpoolRequestCode::Transmit)); + + // ---- S7: process programs ------------------------------------------------- + expect("S7F1 PP load inquire", gem::s7f1_pp_load_inquire("RECIPE-X", 64u)); + expect("S7F3 PP send", gem::s7f3_process_program_send("RECIPE-NEW", "step1\n")); + expect("S7F5 PP request", gem::s7f5_process_program_request("RECIPE-A")); + expect("S7F19 EPPD request", gem::s7f19_current_eppd_request()); + expect("S7F17 PP delete", gem::s7f17_delete_pp_send({"RECIPE-NEW"})); + + // ---- S10: terminal services ----------------------------------------------- + expect("S10F1 Terminal single", gem::s10f1_terminal_display_single(0, "probe")); + expect("S10F3 Terminal single", gem::s10f3_terminal_display_single(0, "probe")); + expect("S10F5 Terminal multi", gem::s10f5_terminal_display_multi(0, {"l1", "l2"})); + + // ---- S14/S16: E39 objects + E40/E94 jobs ---------------------------------- + expect("S14F1 GetAttr", gem::s14f1_get_attr("OBJ", "EQUIPMENT", {})); + expect("S14F3 SetAttr", gem::s14f3_set_attr("LP-1", "MaterialLocation", + {{"PORT_STATE", s2::Item::ascii("OPEN")}})); + + gem::PRJobCreateRequest pj_req{ + "PJ-T-1", gem::MaterialFlag::Substrate, + gem::ProcessRecipeMethod::RecipeOnly, + gem::RecipeSpec{"RECIPE-A", {}}, {"WFR-1"}, {}}; + auto r16f12 = expect("S16F11 PRJobCreate", gem::s16f11_pr_job_create(pj_req)); + (void)r16f12; + CHECK(rt.model().process_jobs.has("PJ-T-1")); + + expect("S16F7 PRJobMonitor", gem::s16f7_pr_job_monitor({{"PJ-T-1", gem::kAlarmEnableByte}})); + expect("S16F15 PRJob multi", gem::s16f15_pr_job_create_multi({{"PJ-M-1", "RECIPE-A", {"W2"}}})); + expect("S14F9 CreateCJ", gem::s14f9_create_control_job("CJ-T-1", {"PJ-T-1"})); + expect("S16F27 CJ command", gem::s16f27_cj_command("CJ-T-1", "CJSTOP")); + expect("S14F11 DeleteCJ", gem::s14f11_delete_control_job("CJ-T-1")); + expect("S16F5 PRJobCommand", gem::s16f5_pr_job_command("PJ-T-1", "PJABORT")); + expect("S16F13 PRJobDequeue", gem::s16f13_pr_job_dequeue("PJ-M-1")); + + // ---- S3: E87 carriers ------------------------------------------------------- + expect("S3F17 CarrierAction", gem::s3f17_carrier_action(0u, "ProceedWithCarrier", + "CAR-T-1", {})); + expect("S3F19 SlotMapVerify", gem::s3f19_slot_map_verify("CAR-T-1", {})); + expect("S3F25 CarrierTransfer", gem::s3f25_carrier_transfer("CAR-T-1", 1, 2)); + expect("S3F27 CancelCarrier", gem::s3f27_cancel_carrier("CAR-T-1")); + + // ---- S1F15 last: takes the equipment OFFLINE ------------------------------ + expect("S1F15 Request OFFLINE", gem::s1f15_request_offline()); + CHECK(rt.control_state() == gem::ControlState::HostOffline); + + // ---- coverage + fallback --------------------------------------------------- + // 53 of the 56 registered handlers are reachable via paired request/reply + // dispatch here. The remaining three need a live wire or daemon context. + CHECK_MESSAGE(covered.size() >= 53, "covered " << covered.size() << " handlers"); + + // Unregistered primaries with W=1 must come back as SxF0 (abort), per the + // Router's E5 default. + auto abort_reply = rt.router().dispatch(s2::Message(1, 99, true)); + REQUIRE(abort_reply.has_value()); + CHECK(abort_reply->stream == 1); + CHECK(abort_reply->function == 0); + + rt.poll(); // drain any emit_event side effects (e.g. START's CEID 300) +} + +// ---- message-level golden frame ------------------------------------------- +// Hand-computed from the E5 encoding rules (NOT generated by our codec), so +// it is an external pin on message composition: format byte = (code<<2) | +// n-length-bytes; List=0o00, ASCII=0o20. +// L[2] -> 0x01 0x02 +// A "HOST" -> 0x41 0x04 'H' 'O' 'S' 'T' +// A "1.0" -> 0x41 0x03 '1' '.' '0' +// TODO(tests): extend with golden frames for S5F1 and a composed S6F11 once +// their ALID/CEID item formats are pinned down from the catalog. +TEST_CASE("golden frame: S1F13 body encodes to the hand-computed E5 bytes") { + auto msg = gem::s1f13_establish_comms("HOST", "1.0"); + REQUIRE(msg.body.has_value()); + const std::vector expected = { + 0x01, 0x02, + 0x41, 0x04, 'H', 'O', 'S', 'T', + 0x41, 0x03, '1', '.', '0', + }; + CHECK(s2::encode(*msg.body) == expected); + CHECK(msg.stream == 1); + CHECK(msg.function == 13); + CHECK(msg.reply_expected); +}