E40 Process Jobs + E94 Control Jobs + E30 communication state

GEM300 layer: SEMI E40-0705 Process Job and E94-0705 Control Job
state machines, plus the E30 §6.1 communication-state machine that
sits between HSMS SELECT and full GEM communication. Data-driven
via data/process_job_state.yaml and data/control_job_state.yaml,
mirroring the existing control_state.yaml pattern.

Wire coverage:
  S14F9/F10   CreateObject (CJ)              host -> equipment
  S14F11/F12  DeleteObject (CJ)              host -> equipment
  S16F5/F6    PRJobCommand                   host -> equipment
  S16F9       PRJobAlert                     equipment -> host
  S16F11/F12  PRJobCreate (simplified body)  host -> equipment
  S16F13/F14  PRJobDequeue                   host -> equipment
  S16F27/F28  CJobCommand                    host -> equipment

Process Job FSM exposes 8 states matching PRJOBSTATE bytes (E40 §10.3.2);
HOQ is reorder-aware (move-to-head against an insertion-order vector);
Stop/Abort on a Queued PJ routes through ABORTING so the host observes
PRJOBSTATE=7 on the wire (§6.3); alert_enabled is settable per-PJ for
PRALERT control; FSM dispatches through ProcessJobStore::on_change_
dynamically so a late set_state_change_handler() reaches existing PJs.

Hardening: loader rejects NoState (sentinel) as initial/from/to and
rejects `on: created` rows; static_asserts pin enum values to wire
bytes; ProcessJobStore is non-movable to keep the per-PJ this-capture
safe.

Server simulator cascades the full CJ -> PJ lifecycle on CJSTART so
the wire trace exercises every legal state. CEIDs 400/401 fire on CJ
state changes via the existing event-report pipeline.

Tests: 60+ new assertions across test_process_jobs, test_control_jobs,
test_communication_state, test_hsms_connection, plus loader and
messages round-trip coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:00:32 +02:00
parent 1f67aad985
commit 90c177b7ce
33 changed files with 3122 additions and 62 deletions
+181
View File
@@ -0,0 +1,181 @@
// Unit tests for the E30 §6.5 GEM Communication State Model.
//
// The state machine is pure logic — no Asio, no sockets — so we drive
// it directly: arm_t_cra() and arm_t_delay() are recorded into a
// `Recorder` struct and we call on_cra_timeout() / on_delay_elapsed()
// by hand to simulate the timers firing.
#include <doctest/doctest.h>
#include <chrono>
#include <vector>
#include "secsgem/gem/communication_state.hpp"
using namespace secsgem::gem;
namespace {
struct Recorder {
bool sent_s1f13 = false;
int cra_arms = 0;
int delay_arms = 0;
int cancels = 0;
std::vector<std::pair<CommState, CommState>> transitions;
};
CommEnvironment env_for(Recorder& r) {
CommEnvironment e;
e.arm_t_cra = [&r](std::chrono::milliseconds) { ++r.cra_arms; };
e.arm_t_delay = [&r](std::chrono::milliseconds) { ++r.delay_arms; };
e.cancel_timers = [&r]() { ++r.cancels; };
e.send_s1f13 = [&r]() { r.sent_s1f13 = true; };
return e;
}
CommStateChangeHandler tracker_for(Recorder& r) {
return [&r](CommState from, CommState to, const std::string&) {
r.transitions.emplace_back(from, to);
};
}
} // namespace
TEST_CASE("Initial state is DISABLED") {
CommunicationStateMachine sm;
CHECK(sm.state() == CommState::Disabled);
CHECK_FALSE(sm.communicating());
}
TEST_CASE("Equipment-initiated enable -> WAIT-CRA, fires S1F13, arms T_CRA") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.set_state_change_handler(tracker_for(r));
sm.enable();
CHECK(sm.state() == CommState::WaitCRA);
CHECK(r.sent_s1f13);
CHECK(r.cra_arms == 1);
CHECK(r.delay_arms == 0);
CHECK(r.transitions.size() == 1);
}
TEST_CASE("S1F14 with COMMACK=Accept -> COMMUNICATING") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(0);
CHECK(sm.state() == CommState::Communicating);
CHECK(sm.communicating());
CHECK(r.cancels >= 1);
}
TEST_CASE("S1F14 with COMMACK=Denied -> WAIT-DELAY and arms T_DELAY") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(1); // Denied
CHECK(sm.state() == CommState::WaitDelay);
CHECK(r.delay_arms == 1);
}
TEST_CASE("T_CRA timeout -> WAIT-DELAY") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_cra_timeout();
CHECK(sm.state() == CommState::WaitDelay);
CHECK(r.delay_arms == 1);
}
TEST_CASE("T_DELAY elapsed -> retry: back to WAIT-CRA and re-send S1F13") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_cra_timeout(); // -> WaitDelay
r.sent_s1f13 = false; // arm to detect the re-send
sm.on_delay_elapsed();
CHECK(sm.state() == CommState::WaitCRA);
CHECK(r.sent_s1f13); // S1F13 re-sent on retry
CHECK(r.cra_arms == 2); // T_CRA armed again
}
TEST_CASE("Disable from any state returns to DISABLED and cancels timers") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(0); // COMMUNICATING
REQUIRE(sm.communicating());
sm.disable();
CHECK(sm.state() == CommState::Disabled);
CHECK(r.cancels >= 1);
}
TEST_CASE("Host-initiated mode: enable parks in WAIT-CRA without sending S1F13") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Host);
sm.set_environment(env_for(r));
sm.enable();
CHECK(sm.state() == CommState::WaitCRA);
CHECK_FALSE(r.sent_s1f13);
CHECK(r.cra_arms == 0); // no T_CRA from our side
}
TEST_CASE("Host-initiated: inbound S1F13 transitions us to COMMUNICATING") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Host);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f13_received();
CHECK(sm.state() == CommState::Communicating);
}
TEST_CASE("Connection lost from COMMUNICATING returns to NOT-COMMUNICATING") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(0); // -> COMMUNICATING
sm.on_connection_lost();
// Equipment-initiated falls into WAIT-DELAY (so it retries).
CHECK(sm.state() == CommState::WaitDelay);
CHECK(r.delay_arms == 1);
}
TEST_CASE("Spurious S1F14 outside WAIT-CRA is ignored") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
// Disabled: still no transition.
sm.on_s1f14_received(0);
CHECK(sm.state() == CommState::Disabled);
sm.enable();
sm.on_s1f14_received(0); // -> COMMUNICATING
REQUIRE(sm.state() == CommState::Communicating);
// Already communicating: a second spurious S1F14 doesn't move us back.
sm.on_s1f14_received(0);
CHECK(sm.state() == CommState::Communicating);
}
TEST_CASE("State name strings cover all four states") {
CHECK(std::string(comm_state_name(CommState::Disabled)) == "DISABLED");
CHECK(std::string(comm_state_name(CommState::WaitCRA)) == "WAIT-CRA");
CHECK(std::string(comm_state_name(CommState::WaitDelay)) == "WAIT-DELAY");
CHECK(std::string(comm_state_name(CommState::Communicating)) == "COMMUNICATING");
}