cf230d4119
Item 8a — ConfigValidator warns on non-identifier variable/event/alarm/ command names ([A-Za-z_][A-Za-z0-9_]*): language bindings expose names as kwargs/attributes, so 'Chamber Pressure' would be unusable in the planned Python client. Warning not error — the wire doesn't care. Tested (4 warning sites + good-name negative). Item 4 tail — golden frames for S5F1 (Binary ALCD / U4 ALID / ASCII ALTX) and a composed S6F11 (the production-critical report shape), bytes hand- computed from E5 encoding rules: external pins on message composition. Item 7 — equipment_service.hpp moved to include/secsgem/daemon/ (apps/ include-path hack removed) and a TSan daemon lane added locally + in CI. tools/tsan.supp suppresses races whose accesses sit entirely inside the UNinstrumented system libgrpc/libgpr/libabsl (epoll wakeups, absl Mutex GraphCycles bookkeeping); our frames stay fully checked. The lane earned its keep on first run: it caught a REAL threading-contract violation — a daemon test reading model stores from the test thread while the io thread serviced posted writes — fixed to use read_sync, exactly per the documented contract. Now TSan-clean under halt_on_error=1 in the full production threading shape. Suites: core 470/3068, daemon Release+TSan 125/125 each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
225 lines
11 KiB
C++
225 lines
11 KiB
C++
// 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 <doctest/doctest.h>
|
|
|
|
#include <set>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#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<std::pair<int, int>> 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'
|
|
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<uint8_t> 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);
|
|
}
|
|
|
|
// S5F1 = L[3]{ Binary ALCD, U4 ALID, ASCII ALTX }. Format bytes per E5:
|
|
// Binary(0o10=8)->0x21, U4(0o54=44)->0xB1, ASCII(0o20=16)->0x41 (1 len byte).
|
|
TEST_CASE("golden frame: S5F1 alarm report encodes to the hand-computed E5 bytes") {
|
|
auto msg = gem::s5f1_alarm_report(0x84, 1, "Chiller Temp High");
|
|
REQUIRE(msg.body.has_value());
|
|
std::vector<uint8_t> expected = {
|
|
0x01, 0x03, // L/3
|
|
0x21, 0x01, 0x84, // B ALCD = set|category4
|
|
0xB1, 0x04, 0x00, 0x00, 0x00, 0x01, // U4 ALID = 1
|
|
0x41, 0x11, // A/17
|
|
};
|
|
for (char c : std::string("Chiller Temp High")) expected.push_back(c);
|
|
CHECK(s2::encode(*msg.body) == expected);
|
|
CHECK(msg.stream == 5);
|
|
CHECK(msg.function == 1);
|
|
}
|
|
|
|
// Composed S6F11 = L[3]{ U4 DATAID, U4 CEID, L reports[ L[2]{U4 RPTID,
|
|
// L values} ] } — the production-critical event-report shape, pinned for one
|
|
// report (RPTID 1) carrying one ASCII value.
|
|
TEST_CASE("golden frame: composed S6F11 encodes to the hand-computed E5 bytes") {
|
|
std::vector<gem::ReportData> reports{{1, {s2::Item::ascii("x")}}};
|
|
auto msg = gem::s6f11_event_report(0, 300, reports);
|
|
REQUIRE(msg.body.has_value());
|
|
const std::vector<uint8_t> expected = {
|
|
0x01, 0x03, // L/3
|
|
0xB1, 0x04, 0x00, 0x00, 0x00, 0x00, // U4 DATAID = 0
|
|
0xB1, 0x04, 0x00, 0x00, 0x01, 0x2C, // U4 CEID = 300
|
|
0x01, 0x01, // L/1 reports
|
|
0x01, 0x02, // L/2 report
|
|
0xB1, 0x04, 0x00, 0x00, 0x00, 0x01, // U4 RPTID = 1
|
|
0x01, 0x01, // L/1 values
|
|
0x41, 0x01, 'x', // A "x"
|
|
};
|
|
CHECK(s2::encode(*msg.body) == expected);
|
|
}
|