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");
}
+94
View File
@@ -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);
}
+30
View File
@@ -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);
}
+231
View File
@@ -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
View File
@@ -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);
}
+180
View File
@@ -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);
}
+223
View File
@@ -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));
}
+36
View File
@@ -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 >");
}