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:
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/control_job_state.hpp"
|
||||
#include "secsgem/gem/store/control_jobs.hpp"
|
||||
#include "secsgem/gem/store/process_jobs.hpp"
|
||||
|
||||
using namespace secsgem::gem;
|
||||
|
||||
TEST_CASE("CJ default initial state is Queued") {
|
||||
ControlJobStateMachine cj;
|
||||
CHECK(cj.state() == ControlJobState::Queued);
|
||||
}
|
||||
|
||||
TEST_CASE("CJ full cascade: Queued -> Completed via Start path") {
|
||||
ControlJobStateMachine cj;
|
||||
CHECK(cj.on_internal(ControlJobEvent::Select));
|
||||
CHECK(cj.state() == ControlJobState::Selected);
|
||||
CHECK(cj.on_internal(ControlJobEvent::SetupComplete));
|
||||
CHECK(cj.state() == ControlJobState::WaitingForStart);
|
||||
CHECK(cj.on_host_command(ControlJobEvent::Start) == HostCmdAck::Accept);
|
||||
CHECK(cj.state() == ControlJobState::Executing);
|
||||
CHECK(cj.on_internal(ControlJobEvent::AllJobsComplete));
|
||||
CHECK(cj.state() == ControlJobState::Completed);
|
||||
}
|
||||
|
||||
TEST_CASE("CJ Pause/Resume only from Executing") {
|
||||
ControlJobStateMachine cj(ControlJobTransitionTable::default_table(),
|
||||
ControlJobState::Executing);
|
||||
CHECK(cj.on_host_command(ControlJobEvent::Pause) == HostCmdAck::Accept);
|
||||
CHECK(cj.state() == ControlJobState::Paused);
|
||||
CHECK(cj.on_host_command(ControlJobEvent::Resume) == HostCmdAck::Accept);
|
||||
CHECK(cj.state() == ControlJobState::Executing);
|
||||
}
|
||||
|
||||
TEST_CASE("CJ Stop from Queued goes directly to Completed (empty CJ)") {
|
||||
ControlJobStateMachine cj;
|
||||
CHECK(cj.on_host_command(ControlJobEvent::Stop) == HostCmdAck::Accept);
|
||||
CHECK(cj.state() == ControlJobState::Completed);
|
||||
}
|
||||
|
||||
TEST_CASE("CJ Abort from Executing -> Aborting -> Completed") {
|
||||
ControlJobStateMachine cj(ControlJobTransitionTable::default_table(),
|
||||
ControlJobState::Executing);
|
||||
CHECK(cj.on_host_command(ControlJobEvent::Abort) == HostCmdAck::Accept);
|
||||
CHECK(cj.state() == ControlJobState::Aborting);
|
||||
CHECK(cj.on_internal(ControlJobEvent::AbortComplete));
|
||||
CHECK(cj.state() == ControlJobState::Completed);
|
||||
}
|
||||
|
||||
TEST_CASE("CJ Start from Queued is illegal (must Select first)") {
|
||||
ControlJobStateMachine cj;
|
||||
CHECK(cj.on_host_command(ControlJobEvent::Start) == HostCmdAck::CannotDoNow);
|
||||
CHECK(cj.state() == ControlJobState::Queued);
|
||||
}
|
||||
|
||||
TEST_CASE("CTLJOBCMD wire-name mapping") {
|
||||
CHECK(ctl_cmd_to_event("CJSTART").value() == ControlJobEvent::Start);
|
||||
CHECK(ctl_cmd_to_event("CJPAUSE").value() == ControlJobEvent::Pause);
|
||||
CHECK(ctl_cmd_to_event("STOP").value() == ControlJobEvent::Stop);
|
||||
CHECK_FALSE(ctl_cmd_to_event("PJSTART").has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("CJStore: create rejects empty PJ list and unknown PJ") {
|
||||
ProcessJobStore pjs;
|
||||
pjs.create("PJ-1", "R", {});
|
||||
ControlJobStore cjs;
|
||||
auto pj_known = [&pjs](const std::string& id) { return pjs.has(id); };
|
||||
|
||||
CHECK(cjs.create("CJ-1", {}, pj_known) ==
|
||||
ControlJobStore::CreateResult::Denied_Empty);
|
||||
CHECK(cjs.create("CJ-1", {"GHOST"}, pj_known) ==
|
||||
ControlJobStore::CreateResult::Denied_UnknownPRJob);
|
||||
CHECK(cjs.create("CJ-1", {"PJ-1"}, pj_known) ==
|
||||
ControlJobStore::CreateResult::Created);
|
||||
CHECK(cjs.create("CJ-1", {"PJ-1"}, pj_known) ==
|
||||
ControlJobStore::CreateResult::Denied_AlreadyExists);
|
||||
}
|
||||
|
||||
TEST_CASE("CJStore: state-change handler observes synthetic Created event") {
|
||||
ProcessJobStore pjs;
|
||||
pjs.create("PJ-1", "R", {});
|
||||
ControlJobStore cjs;
|
||||
std::vector<std::tuple<std::string, ControlJobState, ControlJobState>> log;
|
||||
cjs.set_state_change_handler(
|
||||
[&log](const std::string& id, ControlJobState f, ControlJobState t,
|
||||
ControlJobEvent) { log.emplace_back(id, f, t); });
|
||||
cjs.create("CJ-1", {"PJ-1"});
|
||||
REQUIRE(log.size() == 1);
|
||||
CHECK(std::get<0>(log[0]) == "CJ-1");
|
||||
CHECK(std::get<1>(log[0]) == ControlJobState::NoState);
|
||||
CHECK(std::get<2>(log[0]) == ControlJobState::Queued);
|
||||
}
|
||||
@@ -87,3 +87,33 @@ TEST_CASE("frame decode rejects short payload") {
|
||||
std::vector<uint8_t> tooShort(5, 0);
|
||||
CHECK_THROWS_AS(Frame::decode(tooShort.data(), tooShort.size()), FrameError);
|
||||
}
|
||||
|
||||
TEST_CASE("E37 SType / status / reject-reason wire values") {
|
||||
// SType (E37 §8.3). Defined set is {0,1,2,3,4,5,6,7,9}.
|
||||
CHECK(static_cast<uint8_t>(SType::Data) == 0);
|
||||
CHECK(static_cast<uint8_t>(SType::SelectReq) == 1);
|
||||
CHECK(static_cast<uint8_t>(SType::SelectRsp) == 2);
|
||||
CHECK(static_cast<uint8_t>(SType::DeselectReq) == 3);
|
||||
CHECK(static_cast<uint8_t>(SType::DeselectRsp) == 4);
|
||||
CHECK(static_cast<uint8_t>(SType::LinktestReq) == 5);
|
||||
CHECK(static_cast<uint8_t>(SType::LinktestRsp) == 6);
|
||||
CHECK(static_cast<uint8_t>(SType::RejectReq) == 7);
|
||||
CHECK(static_cast<uint8_t>(SType::SeparateReq) == 9);
|
||||
|
||||
// Select.rsp status (E37 §7.2).
|
||||
CHECK(static_cast<uint8_t>(SelectStatus::Ok) == 0);
|
||||
CHECK(static_cast<uint8_t>(SelectStatus::AlreadyActive) == 1);
|
||||
CHECK(static_cast<uint8_t>(SelectStatus::NotReady) == 2);
|
||||
CHECK(static_cast<uint8_t>(SelectStatus::ConnectExhaust) == 3);
|
||||
|
||||
// Deselect.rsp status (E37 §7.4).
|
||||
CHECK(static_cast<uint8_t>(DeselectStatus::Ok) == 0);
|
||||
CHECK(static_cast<uint8_t>(DeselectStatus::NotEstablished) == 1);
|
||||
CHECK(static_cast<uint8_t>(DeselectStatus::Busy) == 2);
|
||||
|
||||
// Reject reason (E37 §7.7).
|
||||
CHECK(static_cast<uint8_t>(RejectReason::StypeNotSupported) == 1);
|
||||
CHECK(static_cast<uint8_t>(RejectReason::PtypeNotSupported) == 2);
|
||||
CHECK(static_cast<uint8_t>(RejectReason::TransactionNotOpen) == 3);
|
||||
CHECK(static_cast<uint8_t>(RejectReason::EntityNotSelected) == 4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Integration tests for the HSMS Connection state machine.
|
||||
//
|
||||
// We open a pair of connected TCP sockets on loopback, wrap one in a real
|
||||
// passive Connection (the system under test), and use the other as a raw
|
||||
// "peer" socket that hand-builds wire frames. This lets us assert exactly
|
||||
// what bytes the Connection produces in response to specific stimuli — in
|
||||
// particular the E37 §7.2 / §7.4 / §7.7 corner cases that aren't exercised
|
||||
// by the happy-path demo: SelectReq while already SELECTED, DeselectReq
|
||||
// while NOT_SELECTED, and Reject.req emission for unsupported SType / PType.
|
||||
//
|
||||
// Both ends share the same io_context, so all socket I/O on the "peer"
|
||||
// side has to be async too — running an asio::read synchronously on the
|
||||
// peer would block the thread that also has to drive the Connection's
|
||||
// own async reads, deadlocking the test.
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <asio.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/hsms/connection.hpp"
|
||||
#include "secsgem/hsms/header.hpp"
|
||||
|
||||
using namespace secsgem::hsms;
|
||||
|
||||
namespace {
|
||||
|
||||
// Pair of TCP sockets connected over loopback; both ends share `io`.
|
||||
struct SocketPair {
|
||||
asio::io_context io;
|
||||
asio::ip::tcp::socket a{io}; // the "system under test" side
|
||||
asio::ip::tcp::socket b{io}; // the "raw peer" side
|
||||
|
||||
SocketPair() {
|
||||
asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint(
|
||||
asio::ip::address_v4::loopback(), 0));
|
||||
const auto port = acc.local_endpoint().port();
|
||||
|
||||
std::error_code ec_accept;
|
||||
bool accepted = false;
|
||||
acc.async_accept(a, [&](std::error_code ec) { ec_accept = ec; accepted = true; });
|
||||
|
||||
std::error_code ec_connect;
|
||||
bool connected = false;
|
||||
b.async_connect(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), port),
|
||||
[&](std::error_code ec) { ec_connect = ec; connected = true; });
|
||||
|
||||
while (!(accepted && connected)) {
|
||||
if (io.stopped()) io.restart();
|
||||
if (io.poll() == 0) std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
REQUIRE_FALSE(ec_accept);
|
||||
REQUIRE_FALSE(ec_connect);
|
||||
}
|
||||
};
|
||||
|
||||
// Run the io_context until `pred()` returns true or `budget` is exhausted.
|
||||
// We drain all currently-ready handlers with poll(), then sleep briefly
|
||||
// before re-checking — run_one_for() can block for its full timeout even
|
||||
// when ready work exists, which made earlier iterations of this helper
|
||||
// look hung.
|
||||
template <typename Pred>
|
||||
void pump_until(asio::io_context& io, Pred pred,
|
||||
std::chrono::milliseconds budget = std::chrono::seconds(5)) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + budget;
|
||||
while (!pred()) {
|
||||
if (std::chrono::steady_clock::now() > deadline) FAIL("pump_until budget exceeded");
|
||||
if (io.stopped()) io.restart();
|
||||
const std::size_t n = io.poll();
|
||||
if (n == 0) std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Async write a buffer; pumps the io_context until it completes.
|
||||
void send_bytes(SocketPair& sp, std::vector<uint8_t> bytes) {
|
||||
auto buf = std::make_shared<std::vector<uint8_t>>(std::move(bytes));
|
||||
bool done = false;
|
||||
asio::async_write(sp.b, asio::buffer(*buf),
|
||||
[buf, &done](std::error_code ec, std::size_t) {
|
||||
REQUIRE_FALSE(ec);
|
||||
done = true;
|
||||
});
|
||||
pump_until(sp.io, [&] { return done; });
|
||||
}
|
||||
|
||||
// Async read one full HSMS frame from the peer socket; pumps the io_context.
|
||||
Frame recv_frame(SocketPair& sp) {
|
||||
auto lenbuf = std::make_shared<std::array<uint8_t, 4>>();
|
||||
bool len_done = false;
|
||||
asio::async_read(sp.b, asio::buffer(*lenbuf),
|
||||
[lenbuf, &len_done](std::error_code ec, std::size_t) {
|
||||
REQUIRE_FALSE(ec);
|
||||
len_done = true;
|
||||
});
|
||||
pump_until(sp.io, [&] { return len_done; });
|
||||
|
||||
const uint32_t len = (uint32_t((*lenbuf)[0]) << 24) | (uint32_t((*lenbuf)[1]) << 16) |
|
||||
(uint32_t((*lenbuf)[2]) << 8) | uint32_t((*lenbuf)[3]);
|
||||
auto payload = std::make_shared<std::vector<uint8_t>>(len);
|
||||
bool payload_done = false;
|
||||
asio::async_read(sp.b, asio::buffer(*payload),
|
||||
[payload, &payload_done](std::error_code ec, std::size_t) {
|
||||
REQUIRE_FALSE(ec);
|
||||
payload_done = true;
|
||||
});
|
||||
pump_until(sp.io, [&] { return payload_done; });
|
||||
return Frame::decode(payload->data(), payload->size());
|
||||
}
|
||||
|
||||
Timers default_timers() {
|
||||
Timers t;
|
||||
t.linktest = std::chrono::milliseconds(0); // disabled in tests
|
||||
return t;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Select.req while already SELECTED returns AlreadyActive (E37 §7.2)") {
|
||||
SocketPair sp;
|
||||
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
|
||||
/*device_id=*/0, default_timers());
|
||||
bool selected = false;
|
||||
conn->set_selected_handler([&] { selected = true; });
|
||||
conn->start();
|
||||
|
||||
// First Select.req: should be answered with Ok (status=0) and transition
|
||||
// the connection into SELECTED.
|
||||
send_bytes(sp, Frame(Header::control(SType::SelectReq, /*sys=*/1)).encode());
|
||||
Frame rsp1 = recv_frame(sp);
|
||||
CHECK(rsp1.header.stype == SType::SelectRsp);
|
||||
CHECK(rsp1.header.byte3 == static_cast<uint8_t>(SelectStatus::Ok));
|
||||
pump_until(sp.io, [&] { return selected; });
|
||||
|
||||
// Second Select.req while already SELECTED: must reply AlreadyActive (1)
|
||||
// and must NOT re-fire the selected handler.
|
||||
selected = false;
|
||||
send_bytes(sp, Frame(Header::control(SType::SelectReq, /*sys=*/2)).encode());
|
||||
Frame rsp2 = recv_frame(sp);
|
||||
CHECK(rsp2.header.stype == SType::SelectRsp);
|
||||
CHECK(rsp2.header.byte3 == static_cast<uint8_t>(SelectStatus::AlreadyActive));
|
||||
CHECK_FALSE(selected);
|
||||
|
||||
conn->close("test done");
|
||||
}
|
||||
|
||||
TEST_CASE("Deselect.req while NOT_SELECTED returns NotEstablished (E37 §7.4)") {
|
||||
SocketPair sp;
|
||||
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
|
||||
/*device_id=*/0, default_timers());
|
||||
conn->start();
|
||||
|
||||
send_bytes(sp, Frame(Header::control(SType::DeselectReq, /*sys=*/1)).encode());
|
||||
Frame rsp = recv_frame(sp);
|
||||
CHECK(rsp.header.stype == SType::DeselectRsp);
|
||||
CHECK(rsp.header.byte3 == static_cast<uint8_t>(DeselectStatus::NotEstablished));
|
||||
CHECK(conn->state() == Connection::State::NotSelected);
|
||||
|
||||
conn->close("test done");
|
||||
}
|
||||
|
||||
TEST_CASE("Unsupported SType triggers Reject.req(StypeNotSupported) (E37 §7.7)") {
|
||||
SocketPair sp;
|
||||
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
|
||||
/*device_id=*/0, default_timers());
|
||||
conn->start();
|
||||
|
||||
// Standalone Header doesn't let us produce an unknown SType via the
|
||||
// constructors, so we build the wire bytes by hand.
|
||||
std::vector<uint8_t> bad = {0x00, 0x00, 0x00, 0x0A,
|
||||
// header: session=0xFFFF, byte2=0, byte3=0,
|
||||
// ptype=0, stype=10, sys=5
|
||||
0xFF, 0xFF, 0x00, 0x00, 0x00, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x05};
|
||||
send_bytes(sp, std::move(bad));
|
||||
|
||||
Frame rej = recv_frame(sp);
|
||||
CHECK(rej.header.stype == SType::RejectReq);
|
||||
CHECK(rej.header.system_bytes == 5);
|
||||
CHECK(rej.header.byte2 == 10); // offending SType echoed in byte2
|
||||
CHECK(rej.header.byte3 == static_cast<uint8_t>(RejectReason::StypeNotSupported));
|
||||
|
||||
conn->close("test done");
|
||||
}
|
||||
|
||||
TEST_CASE("Non-zero PType triggers Reject.req(PtypeNotSupported) (E37 §7.7)") {
|
||||
SocketPair sp;
|
||||
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
|
||||
/*device_id=*/0, default_timers());
|
||||
conn->start();
|
||||
|
||||
// Linktest.req-like frame but with PType=7 (unsupported).
|
||||
std::vector<uint8_t> bad = {0x00, 0x00, 0x00, 0x0A,
|
||||
0xFF, 0xFF, 0x00, 0x00,
|
||||
/*ptype=*/0x07, /*stype=LinktestReq*/0x05,
|
||||
0x00, 0x00, 0x00, 0x09};
|
||||
send_bytes(sp, std::move(bad));
|
||||
|
||||
Frame rej = recv_frame(sp);
|
||||
CHECK(rej.header.stype == SType::RejectReq);
|
||||
CHECK(rej.header.system_bytes == 9);
|
||||
CHECK(rej.header.byte2 == 7); // offending PType echoed in byte2
|
||||
CHECK(rej.header.byte3 == static_cast<uint8_t>(RejectReason::PtypeNotSupported));
|
||||
|
||||
conn->close("test done");
|
||||
}
|
||||
|
||||
TEST_CASE("Data frame while NOT_SELECTED triggers Reject.req(EntityNotSelected)") {
|
||||
SocketPair sp;
|
||||
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
|
||||
/*device_id=*/0, default_timers());
|
||||
conn->start();
|
||||
|
||||
// Primary S1F1 W=1 before selecting — equipment must Reject with
|
||||
// EntityNotSelected. Construct via the header API: Data with an empty
|
||||
// body is a valid wire frame.
|
||||
Frame data(Header::data_message(/*session=*/1, /*stream=*/1, /*function=*/1,
|
||||
/*reply_expected=*/true, /*sys=*/42));
|
||||
send_bytes(sp, data.encode());
|
||||
|
||||
Frame rej = recv_frame(sp);
|
||||
CHECK(rej.header.stype == SType::RejectReq);
|
||||
CHECK(rej.header.byte3 == static_cast<uint8_t>(RejectReason::EntityNotSelected));
|
||||
|
||||
conn->close("test done");
|
||||
}
|
||||
+47
-1
@@ -47,9 +47,11 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman
|
||||
CHECK(m.ecids.all().size() == 2);
|
||||
CHECK(m.ecids.get(10)->value == s2::Item::u4(uint32_t{1}));
|
||||
|
||||
CHECK(m.events.all_events().size() == 3);
|
||||
CHECK(m.events.all_events().size() >= 3);
|
||||
CHECK(m.events.has_event(100));
|
||||
CHECK(m.events.has_event(300));
|
||||
CHECK(m.events.has_event(400)); // E94 CJ Executing
|
||||
CHECK(m.events.has_event(401)); // E94 CJ Completed
|
||||
|
||||
CHECK(m.alarms.all().size() == 2);
|
||||
CHECK(m.alarms.get(1)->text == "Chiller Temp High");
|
||||
@@ -98,3 +100,47 @@ TEST_CASE("loader surfaces YAML errors with file path") {
|
||||
CHECK_THROWS_AS(config::load_equipment("/tmp/does-not-exist.yaml", *new gem::EquipmentDataModel),
|
||||
config::ConfigError);
|
||||
}
|
||||
|
||||
TEST_CASE("loader: process_job_state.yaml -> non-empty table") {
|
||||
auto cfg = config::load_process_job_state(
|
||||
std::string(SECSGEM_DATA_DIR) + "/process_job_state.yaml");
|
||||
CHECK(cfg.initial == gem::ProcessJobState::Queued);
|
||||
CHECK(cfg.table.size() >= 18);
|
||||
// Spot-check: Queued + Select -> SettingUp.
|
||||
const auto* row = cfg.table.find(gem::ProcessJobState::Queued,
|
||||
gem::ProcessJobEvent::Select);
|
||||
REQUIRE(row != nullptr);
|
||||
REQUIRE(row->to.has_value());
|
||||
CHECK(*row->to == gem::ProcessJobState::SettingUp);
|
||||
}
|
||||
|
||||
TEST_CASE("loader: NoState rejected in PJ state table") {
|
||||
const std::string path = "/tmp/pj_nostate.yaml";
|
||||
std::ofstream(path) << "initial: NoState\ntransitions: []\n";
|
||||
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
|
||||
|
||||
std::ofstream(path) << "initial: Queued\ntransitions:\n"
|
||||
<< " - {from: NoState, on: select, to: SettingUp}\n";
|
||||
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
|
||||
|
||||
std::ofstream(path) << "initial: Queued\ntransitions:\n"
|
||||
<< " - {from: Queued, on: select, to: NoState}\n";
|
||||
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
|
||||
|
||||
// `created` is the synthetic observer event — never legal in the table.
|
||||
std::ofstream(path) << "initial: Queued\ntransitions:\n"
|
||||
<< " - {from: Queued, on: created, to: SettingUp}\n";
|
||||
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
|
||||
}
|
||||
|
||||
TEST_CASE("loader: control_job_state.yaml -> non-empty table") {
|
||||
auto cfg = config::load_control_job_state(
|
||||
std::string(SECSGEM_DATA_DIR) + "/control_job_state.yaml");
|
||||
CHECK(cfg.initial == gem::ControlJobState::Queued);
|
||||
CHECK(cfg.table.size() >= 18);
|
||||
const auto* row = cfg.table.find(gem::ControlJobState::Executing,
|
||||
gem::ControlJobEvent::AllJobsComplete);
|
||||
REQUIRE(row != nullptr);
|
||||
REQUIRE(row->to.has_value());
|
||||
CHECK(*row->to == gem::ControlJobState::Completed);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,67 @@ TEST_CASE("S2F16 EAC ack round-trip") {
|
||||
CHECK(*byte == static_cast<uint8_t>(EquipmentAck::Denied_OutOfRange));
|
||||
}
|
||||
|
||||
TEST_CASE("S2F16 EAC enum matches SEMI E5 wire values") {
|
||||
// Pin the spec values so an inadvertent re-numbering breaks the test
|
||||
// instead of breaking interoperability with conformant hosts.
|
||||
CHECK(static_cast<uint8_t>(EquipmentAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(EquipmentAck::Denied_UnknownEcid) == 1);
|
||||
CHECK(static_cast<uint8_t>(EquipmentAck::Denied_Busy) == 2);
|
||||
CHECK(static_cast<uint8_t>(EquipmentAck::Denied_OutOfRange) == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("S2F34 DRACK enum matches SEMI E5 wire values") {
|
||||
CHECK(static_cast<uint8_t>(DefineReportAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(DefineReportAck::InsufficientSpace) == 1);
|
||||
CHECK(static_cast<uint8_t>(DefineReportAck::InvalidFormat) == 2);
|
||||
CHECK(static_cast<uint8_t>(DefineReportAck::RptidAlreadyDefined) == 3);
|
||||
CHECK(static_cast<uint8_t>(DefineReportAck::InvalidVid) == 4);
|
||||
}
|
||||
|
||||
TEST_CASE("S2F36 LRACK enum matches SEMI E5 wire values") {
|
||||
CHECK(static_cast<uint8_t>(LinkEventAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(LinkEventAck::InsufficientSpace) == 1);
|
||||
CHECK(static_cast<uint8_t>(LinkEventAck::InvalidFormat) == 2);
|
||||
CHECK(static_cast<uint8_t>(LinkEventAck::UnknownCeid) == 3);
|
||||
CHECK(static_cast<uint8_t>(LinkEventAck::UnknownRptid) == 4);
|
||||
CHECK(static_cast<uint8_t>(LinkEventAck::CeidAlreadyLinked) == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("S2F42 HCACK enum matches SEMI E5 wire values") {
|
||||
CHECK(static_cast<uint8_t>(HostCmdAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(HostCmdAck::InvalidCommand) == 1);
|
||||
CHECK(static_cast<uint8_t>(HostCmdAck::CannotDoNow) == 2);
|
||||
CHECK(static_cast<uint8_t>(HostCmdAck::ParameterInvalid) == 3);
|
||||
CHECK(static_cast<uint8_t>(HostCmdAck::AcceptedWillFinishLater) == 4);
|
||||
CHECK(static_cast<uint8_t>(HostCmdAck::Rejected) == 5);
|
||||
CHECK(static_cast<uint8_t>(HostCmdAck::InvalidObject) == 6);
|
||||
}
|
||||
|
||||
TEST_CASE("S7F4 ACKC7 enum matches SEMI E5 wire values") {
|
||||
CHECK(static_cast<uint8_t>(ProcessProgramAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(ProcessProgramAck::PermissionNotGranted) == 1);
|
||||
CHECK(static_cast<uint8_t>(ProcessProgramAck::LengthError) == 2);
|
||||
CHECK(static_cast<uint8_t>(ProcessProgramAck::MatrixOverflow) == 3);
|
||||
CHECK(static_cast<uint8_t>(ProcessProgramAck::PpidNotFound) == 4);
|
||||
CHECK(static_cast<uint8_t>(ProcessProgramAck::ModeUnsupported) == 5);
|
||||
CHECK(static_cast<uint8_t>(ProcessProgramAck::PerformanceError) == 6);
|
||||
}
|
||||
|
||||
TEST_CASE("S10F2/F4/F6 ACKC10 enum matches SEMI E5 wire values") {
|
||||
CHECK(static_cast<uint8_t>(TerminalAck::Accepted) == 0);
|
||||
CHECK(static_cast<uint8_t>(TerminalAck::WillNotDisplay) == 1);
|
||||
CHECK(static_cast<uint8_t>(TerminalAck::TerminalNotAvailable) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("S1F18 ONLACK / S1F16 OFLACK / S1F14 COMMACK enum wire values") {
|
||||
CHECK(static_cast<uint8_t>(OnlineAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(OnlineAck::NotAccept) == 1);
|
||||
CHECK(static_cast<uint8_t>(OnlineAck::AlreadyOnline) == 2);
|
||||
CHECK(static_cast<uint8_t>(OfflineAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(CommAck::Accept) == 0);
|
||||
CHECK(static_cast<uint8_t>(CommAck::Denied) == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("S2F18 carries 16-char time string") {
|
||||
auto m = s2f18_date_time_data("2026052812345678");
|
||||
auto t = parse_s2f18(m);
|
||||
@@ -392,6 +453,51 @@ TEST_CASE("S7F3 process-program send round-trip") {
|
||||
CHECK(parsed->ppbody == "step1\nstep2\n");
|
||||
}
|
||||
|
||||
TEST_CASE("S2F25 / S2F26 loopback diagnostic round-trip") {
|
||||
// Arbitrary binary payload — host sends, equipment echoes back.
|
||||
const std::string payload("\x00\x01\x02\xFE\xFF some text", 14);
|
||||
auto req = s2f25_loopback_diagnostic_request(payload);
|
||||
CHECK(req.stream == 2);
|
||||
CHECK(req.function == 25);
|
||||
CHECK(req.reply_expected);
|
||||
auto parsed_req = parse_s2f25(req);
|
||||
REQUIRE(parsed_req.has_value());
|
||||
CHECK(*parsed_req == payload);
|
||||
|
||||
auto rsp = s2f26_loopback_diagnostic_data(payload);
|
||||
auto parsed_rsp = parse_s2f26(rsp);
|
||||
REQUIRE(parsed_rsp.has_value());
|
||||
CHECK(*parsed_rsp == payload);
|
||||
}
|
||||
|
||||
TEST_CASE("S5F9 / S5F10 exception post round-trip") {
|
||||
std::vector<std::string> recovery = {"RETRY", "ABORT", "MANUAL_INTERVENTION"};
|
||||
auto m = s5f9_exception_post_notify(42, "FATAL", "vacuum lost", recovery);
|
||||
CHECK(m.stream == 5);
|
||||
CHECK(m.function == 9);
|
||||
CHECK(m.reply_expected);
|
||||
|
||||
auto parsed = parse_s5f9(m);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(parsed->exid == 42);
|
||||
CHECK(parsed->extype == "FATAL");
|
||||
CHECK(parsed->exmessage == "vacuum lost");
|
||||
CHECK(parsed->exrecvra == recovery);
|
||||
|
||||
CHECK(*ack_byte(s5f10_exception_post_confirm(AlarmAck::Accept)) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("S5F11 / S5F12 exception clear round-trip") {
|
||||
auto m = s5f11_exception_clear_notify(42, "FATAL", "vacuum restored");
|
||||
auto parsed = parse_s5f11(m);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(parsed->exid == 42);
|
||||
CHECK(parsed->extype == "FATAL");
|
||||
CHECK(parsed->exmessage == "vacuum restored");
|
||||
|
||||
CHECK(*ack_byte(s5f12_exception_clear_confirm(AlarmAck::Accept)) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("S7F19 / S7F20 EPPD list") {
|
||||
auto req = s7f19_current_eppd_request();
|
||||
CHECK(req.stream == 7);
|
||||
@@ -404,3 +510,77 @@ TEST_CASE("S7F19 / S7F20 EPPD list") {
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(*parsed == std::vector<std::string>{"RECIPE-A", "RECIPE-B"});
|
||||
}
|
||||
|
||||
TEST_CASE("S14F9 / S14F10 CreateControlJob round-trip") {
|
||||
auto req = s14f9_create_control_job("CJ-1", {"PJ-1", "PJ-2"});
|
||||
CHECK(req.stream == 14);
|
||||
CHECK(req.function == 9);
|
||||
CHECK(req.reply_expected);
|
||||
|
||||
auto parsed = parse_s14f9(req);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(parsed->ctljobid == "CJ-1");
|
||||
CHECK(parsed->prjobids == std::vector<std::string>{"PJ-1", "PJ-2"});
|
||||
|
||||
auto ack = s14f10_create_control_job_ack("CJ-1", ObjectAck::Success);
|
||||
auto parsed_ack = parse_s14f10(ack);
|
||||
REQUIRE(parsed_ack.has_value());
|
||||
CHECK(parsed_ack->ctljobid == "CJ-1");
|
||||
CHECK(parsed_ack->ack == ObjectAck::Success);
|
||||
}
|
||||
|
||||
TEST_CASE("S14F11 / S14F12 DeleteControlJob round-trip") {
|
||||
auto req = s14f11_delete_control_job("CJ-1");
|
||||
auto parsed = parse_s14f11(req);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(*parsed == "CJ-1");
|
||||
|
||||
auto ack = s14f12_delete_control_job_ack(ObjectAck::Denied_UnknownObject);
|
||||
CHECK(*ack_byte(ack) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("S16F11 / S16F12 PRJobCreate round-trip") {
|
||||
auto req = s16f11_pr_job_create("PJ-1", "RECIPE-A", {"WFR-1", "WFR-2"});
|
||||
CHECK(req.stream == 16);
|
||||
CHECK(req.function == 11);
|
||||
|
||||
auto parsed = parse_s16f11(req);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(parsed->prjobid == "PJ-1");
|
||||
CHECK(parsed->ppid == "RECIPE-A");
|
||||
CHECK(parsed->mtrloutspec == std::vector<std::string>{"WFR-1", "WFR-2"});
|
||||
|
||||
CHECK(*ack_byte(s16f12_pr_job_create_ack(HostCmdAck::Accept)) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("S16F13 / S16F14 PRJobDequeue round-trip") {
|
||||
auto req = s16f13_pr_job_dequeue("PJ-1");
|
||||
CHECK(*parse_s16f13(req) == "PJ-1");
|
||||
CHECK(*ack_byte(s16f14_pr_job_dequeue_ack(HostCmdAck::CannotDoNow)) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("S16F5 / S16F6 PRJobCommand round-trip") {
|
||||
auto req = s16f5_pr_job_command("PJ-1", "PJSTART");
|
||||
auto parsed = parse_s16f5(req);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(parsed->prjobid == "PJ-1");
|
||||
CHECK(parsed->prcmd == "PJSTART");
|
||||
CHECK(*ack_byte(s16f6_pr_job_command_ack(HostCmdAck::Accept)) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("S16F9 PRJobAlert round-trip with typed state byte") {
|
||||
auto m = s16f9_pr_job_alert("PJ-1", ProcessJobState::Processing);
|
||||
auto parsed = parse_s16f9(m);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(parsed->prjobid == "PJ-1");
|
||||
CHECK(parsed->prjobstate == ProcessJobState::Processing);
|
||||
}
|
||||
|
||||
TEST_CASE("S16F27 / S16F28 CJobCommand round-trip") {
|
||||
auto req = s16f27_cj_command("CJ-1", "CJSTART");
|
||||
auto parsed = parse_s16f27(req);
|
||||
REQUIRE(parsed.has_value());
|
||||
CHECK(parsed->ctljobid == "CJ-1");
|
||||
CHECK(parsed->ctljobcmd == "CJSTART");
|
||||
CHECK(*ack_byte(s16f28_cj_command_ack(HostCmdAck::Accept)) == 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/process_job_state.hpp"
|
||||
#include "secsgem/gem/store/process_jobs.hpp"
|
||||
|
||||
using namespace secsgem::gem;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Recorder {
|
||||
std::vector<std::tuple<ProcessJobState, ProcessJobState, ProcessJobEvent>> changes;
|
||||
ProcessJobStateMachine::StateChangeHandler handler() {
|
||||
return [this](ProcessJobState from, ProcessJobState to, ProcessJobEvent ev) {
|
||||
changes.emplace_back(from, to, ev);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("PJ default initial state is Queued") {
|
||||
ProcessJobStateMachine pj;
|
||||
CHECK(pj.state() == ProcessJobState::Queued);
|
||||
}
|
||||
|
||||
TEST_CASE("PJ Queued -> SettingUp on Select (internal)") {
|
||||
ProcessJobStateMachine pj;
|
||||
Recorder rec;
|
||||
pj.set_state_change_handler(rec.handler());
|
||||
CHECK(pj.on_internal(ProcessJobEvent::Select));
|
||||
CHECK(pj.state() == ProcessJobState::SettingUp);
|
||||
REQUIRE(rec.changes.size() == 1);
|
||||
CHECK(std::get<1>(rec.changes[0]) == ProcessJobState::SettingUp);
|
||||
}
|
||||
|
||||
TEST_CASE("PJ full happy-path cascade") {
|
||||
ProcessJobStateMachine pj;
|
||||
CHECK(pj.on_internal(ProcessJobEvent::Select)); // -> SettingUp
|
||||
CHECK(pj.on_internal(ProcessJobEvent::SetupComplete)); // -> WaitingForStart
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Start) == HostCmdAck::Accept);
|
||||
CHECK(pj.state() == ProcessJobState::Processing);
|
||||
CHECK(pj.on_internal(ProcessJobEvent::ProcessComplete));
|
||||
CHECK(pj.state() == ProcessJobState::ProcessComplete);
|
||||
}
|
||||
|
||||
TEST_CASE("PJ Start while Queued rejected (CannotDoNow)") {
|
||||
ProcessJobStateMachine pj;
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Start) == HostCmdAck::CannotDoNow);
|
||||
CHECK(pj.state() == ProcessJobState::Queued);
|
||||
}
|
||||
|
||||
TEST_CASE("PJ Pause/Resume preserves Processing context") {
|
||||
ProcessJobStateMachine pj;
|
||||
pj.on_internal(ProcessJobEvent::Select);
|
||||
pj.on_internal(ProcessJobEvent::SetupComplete);
|
||||
pj.on_host_command(ProcessJobEvent::Start);
|
||||
REQUIRE(pj.state() == ProcessJobState::Processing);
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Pause) == HostCmdAck::Accept);
|
||||
CHECK(pj.state() == ProcessJobState::Paused);
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Resume) == HostCmdAck::Accept);
|
||||
CHECK(pj.state() == ProcessJobState::Processing);
|
||||
}
|
||||
|
||||
TEST_CASE("PJ Abort from any non-terminal state -> Aborting") {
|
||||
// Includes Queued: per E40-0705 §6.3 a Queued PJ stops/aborts via the
|
||||
// Aborting state (not direct-to-ProcessComplete), so the host sees a
|
||||
// PRJOBSTATE=7 alert on the wire.
|
||||
for (auto initial : {ProcessJobState::Queued, ProcessJobState::SettingUp,
|
||||
ProcessJobState::WaitingForStart,
|
||||
ProcessJobState::Processing, ProcessJobState::Paused,
|
||||
ProcessJobState::Stopping}) {
|
||||
ProcessJobStateMachine pj(ProcessJobTransitionTable::default_table(), initial);
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Abort) == HostCmdAck::Accept);
|
||||
CHECK(pj.state() == ProcessJobState::Aborting);
|
||||
CHECK(pj.on_internal(ProcessJobEvent::AbortComplete));
|
||||
CHECK(pj.state() == ProcessJobState::ProcessComplete);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PJ Stop on Queued routes through Aborting") {
|
||||
ProcessJobStateMachine pj;
|
||||
REQUIRE(pj.state() == ProcessJobState::Queued);
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Stop) == HostCmdAck::Accept);
|
||||
CHECK(pj.state() == ProcessJobState::Aborting);
|
||||
CHECK(pj.on_internal(ProcessJobEvent::AbortComplete));
|
||||
CHECK(pj.state() == ProcessJobState::ProcessComplete);
|
||||
}
|
||||
|
||||
TEST_CASE("PJ HeadOfQueue is reorder-only — same state, ack Accept") {
|
||||
ProcessJobStateMachine pj;
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::HeadOfQueue) == HostCmdAck::Accept);
|
||||
CHECK(pj.state() == ProcessJobState::Queued);
|
||||
}
|
||||
|
||||
TEST_CASE("PJ ProcessComplete is terminal — further commands rejected") {
|
||||
ProcessJobStateMachine pj(ProcessJobTransitionTable::default_table(),
|
||||
ProcessJobState::ProcessComplete);
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Start) == HostCmdAck::CannotDoNow);
|
||||
CHECK(pj.on_host_command(ProcessJobEvent::Abort) == HostCmdAck::CannotDoNow);
|
||||
CHECK(pj.state() == ProcessJobState::ProcessComplete);
|
||||
}
|
||||
|
||||
TEST_CASE("PRCMD wire-name mapping") {
|
||||
CHECK(pr_cmd_to_event("PJSTART").value() == ProcessJobEvent::Start);
|
||||
CHECK(pr_cmd_to_event("PJSTOP").value() == ProcessJobEvent::Stop);
|
||||
CHECK(pr_cmd_to_event("PAUSE").value() == ProcessJobEvent::Pause);
|
||||
CHECK(pr_cmd_to_event("PJABORT").value() == ProcessJobEvent::Abort);
|
||||
CHECK(pr_cmd_to_event("PJHOQ").value() == ProcessJobEvent::HeadOfQueue);
|
||||
CHECK_FALSE(pr_cmd_to_event("DELETE").has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("Store: create rejects duplicate PRJOBID") {
|
||||
ProcessJobStore store;
|
||||
CHECK(store.create("PJ-1", "RECIPE", {"W1"}) ==
|
||||
ProcessJobStore::CreateResult::Created);
|
||||
CHECK(store.create("PJ-1", "RECIPE", {"W1"}) ==
|
||||
ProcessJobStore::CreateResult::Denied_AlreadyExists);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: create rejects unknown PPID via validator") {
|
||||
ProcessJobStore store;
|
||||
auto known = [](const std::string& ppid) { return ppid == "RECIPE-A"; };
|
||||
CHECK(store.create("PJ-1", "BAD", {}, known) ==
|
||||
ProcessJobStore::CreateResult::Denied_InvalidPpid);
|
||||
CHECK(store.create("PJ-1", "RECIPE-A", {}, known) ==
|
||||
ProcessJobStore::CreateResult::Created);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: dequeue only legal while Queued") {
|
||||
ProcessJobStore store;
|
||||
store.create("PJ-1", "R", {});
|
||||
CHECK(store.dequeue("PJ-1") == HostCmdAck::Accept);
|
||||
CHECK(store.has("PJ-1") == false);
|
||||
|
||||
store.create("PJ-2", "R", {});
|
||||
store.fire_internal("PJ-2", ProcessJobEvent::Select); // -> SettingUp
|
||||
CHECK(store.dequeue("PJ-2") == HostCmdAck::CannotDoNow);
|
||||
CHECK(store.has("PJ-2") == true);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: state-change handler observes synthetic create event") {
|
||||
ProcessJobStore store;
|
||||
std::vector<std::tuple<std::string, ProcessJobState, ProcessJobState>> log;
|
||||
store.set_state_change_handler(
|
||||
[&log](const std::string& id, ProcessJobState f, ProcessJobState t,
|
||||
ProcessJobEvent) { log.emplace_back(id, f, t); });
|
||||
store.create("PJ-1", "R", {});
|
||||
REQUIRE(log.size() == 1);
|
||||
CHECK(std::get<0>(log[0]) == "PJ-1");
|
||||
CHECK(std::get<1>(log[0]) == ProcessJobState::NoState);
|
||||
CHECK(std::get<2>(log[0]) == ProcessJobState::Queued);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: host command on unknown PJ returns InvalidObject") {
|
||||
ProcessJobStore store;
|
||||
CHECK(store.on_host_command("ghost", ProcessJobEvent::Start) ==
|
||||
HostCmdAck::InvalidObject);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: ids() preserves insertion order") {
|
||||
ProcessJobStore store;
|
||||
store.create("PJ-B", "R", {});
|
||||
store.create("PJ-A", "R", {});
|
||||
store.create("PJ-C", "R", {});
|
||||
REQUIRE(store.ids().size() == 3);
|
||||
CHECK(store.ids()[0] == "PJ-B");
|
||||
CHECK(store.ids()[1] == "PJ-A");
|
||||
CHECK(store.ids()[2] == "PJ-C");
|
||||
}
|
||||
|
||||
TEST_CASE("Store: HOQ moves Queued PJ to head of queue") {
|
||||
ProcessJobStore store;
|
||||
store.create("PJ-1", "R", {});
|
||||
store.create("PJ-2", "R", {});
|
||||
store.create("PJ-3", "R", {});
|
||||
REQUIRE(store.position("PJ-3") == 2);
|
||||
|
||||
CHECK(store.on_host_command("PJ-3", ProcessJobEvent::HeadOfQueue) ==
|
||||
HostCmdAck::Accept);
|
||||
CHECK(store.position("PJ-3") == 0);
|
||||
CHECK(store.position("PJ-1") == 1);
|
||||
CHECK(store.position("PJ-2") == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: HOQ on the head is a no-op Accept") {
|
||||
ProcessJobStore store;
|
||||
store.create("PJ-1", "R", {});
|
||||
store.create("PJ-2", "R", {});
|
||||
CHECK(store.on_host_command("PJ-1", ProcessJobEvent::HeadOfQueue) ==
|
||||
HostCmdAck::Accept);
|
||||
CHECK(store.position("PJ-1") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: HOQ rejected for non-Queued PJ") {
|
||||
ProcessJobStore store;
|
||||
store.create("PJ-1", "R", {});
|
||||
store.create("PJ-2", "R", {});
|
||||
store.fire_internal("PJ-2", ProcessJobEvent::Select); // -> SettingUp
|
||||
CHECK(store.on_host_command("PJ-2", ProcessJobEvent::HeadOfQueue) ==
|
||||
HostCmdAck::CannotDoNow);
|
||||
CHECK(store.position("PJ-2") == 1); // order unchanged
|
||||
}
|
||||
|
||||
TEST_CASE("Store: dequeue removes PJ from order vector") {
|
||||
ProcessJobStore store;
|
||||
store.create("PJ-1", "R", {});
|
||||
store.create("PJ-2", "R", {});
|
||||
CHECK(store.dequeue("PJ-1") == HostCmdAck::Accept);
|
||||
REQUIRE(store.ids().size() == 1);
|
||||
CHECK(store.ids()[0] == "PJ-2");
|
||||
CHECK(store.position("PJ-1") == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("Store: set_alert toggles per-PJ alert flag") {
|
||||
ProcessJobStore store;
|
||||
store.create("PJ-1", "R", {});
|
||||
REQUIRE(store.get("PJ-1")->alert_enabled);
|
||||
CHECK(store.set_alert("PJ-1", false));
|
||||
CHECK(store.get("PJ-1")->alert_enabled == false);
|
||||
CHECK_FALSE(store.set_alert("ghost", false));
|
||||
}
|
||||
@@ -97,3 +97,39 @@ TEST_CASE("SML rendering") {
|
||||
Item body = Item::list({Item::ascii("MDLN"), Item::u4(uint32_t{42})});
|
||||
CHECK(to_sml(body) == "<L [2] <A \"MDLN\" > <U4 42 > >");
|
||||
}
|
||||
|
||||
TEST_CASE("JIS-8 encode/decode (E5 §9.5)") {
|
||||
// Format byte for JIS-8 = 0x11 << 2 | 0x01 = 0x45 with 1-byte length.
|
||||
// 3 bytes payload "abc" (we don't bother with real JIS chars; the wire
|
||||
// format is byte-identical to ASCII, only the format code differs).
|
||||
Item j = Item::jis8("abc");
|
||||
auto bytes = encode(j);
|
||||
CHECK(bytes == std::vector<uint8_t>{0x45, 0x03, 'a', 'b', 'c'});
|
||||
|
||||
Item back = decode(bytes);
|
||||
CHECK(back.format() == Format::JIS8);
|
||||
CHECK(back == j);
|
||||
}
|
||||
|
||||
TEST_CASE("C2 (Unicode 2-byte) encode/decode (E5 §9.5)") {
|
||||
// 0x12 << 2 | 0x01 = 0x49 format byte, 1-byte length, then 2 bytes per
|
||||
// code point big-endian. Code points: U+00E9 (é), U+4E2D (中).
|
||||
Item c = Item::c2({0x00E9, 0x4E2D});
|
||||
auto bytes = encode(c);
|
||||
CHECK(bytes == std::vector<uint8_t>{0x49, 0x04, 0x00, 0xE9, 0x4E, 0x2D});
|
||||
|
||||
Item back = decode(bytes);
|
||||
CHECK(back.format() == Format::C2);
|
||||
CHECK(back == c);
|
||||
}
|
||||
|
||||
TEST_CASE("JIS-8 and C2 disambiguate from ASCII / U2 by Format") {
|
||||
// Same backing storage, different format code → not equal.
|
||||
CHECK(Item::jis8("hi") != Item::ascii("hi"));
|
||||
CHECK(Item::c2({0x41, 0x42}) != Item::u2({0x41, 0x42}));
|
||||
}
|
||||
|
||||
TEST_CASE("SML rendering tags JIS-8 with J and C2 with C") {
|
||||
CHECK(to_sml(Item::jis8("hi")) == "<J \"hi\" >");
|
||||
CHECK(to_sml(Item::c2({0x41, 0x42})) == "<C 65 66 >");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user