tests: CEID/S6F11 + PRJobAlert S16F9 on-the-wire emission

FSM unit tests already verified state transitions fire the change
handler — but they don't prove the frame leaves the socket with the
right CEID and linked report payload. This commit wires a passive
equipment Connection to an EquipmentDataModel via a small emitter,
drives transitions, and asserts on what the host peer receives.

Six new tests:
  EPT → Productive  ⇒ S6F11(kCeidProductive) with the linked report
  EPT (no subscription) ⇒ no S6F11 (proves disable gate)
  PJ Queued→SettingUp  ⇒ S16F9 PRJobAlert with PRJOBID + state byte
  PJ alert_enabled=false  ⇒ no S16F9 (per-PJ gate works)
  CJ → Executing  ⇒ S6F11(ControlJobExecuting) on the wire
  Substrate StartProcessing  ⇒ S6F11(SubstrateInProcess) on the wire

All use the generated parse_s6f11 / parse_s16f9 to decode the
incoming frame and assert against typed fields (CEID, PRJOBID, etc.)
rather than poking variant internals — that ties the test to the
schema-as-data rather than to wire byte offsets.

Closes #9 in the test-gap backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:49:28 +02:00
parent c527caccc5
commit 72da1dc77f
2 changed files with 342 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
// CEID-on-the-wire emission tests.
//
// The FSM unit tests in test_ept / test_process_jobs / test_substrates /
// test_control_jobs / test_modules verify that state transitions fire
// the change handler — but they don't prove the S6F11 (or S16F9) frame
// is what actually leaves the socket with the right CEID + linked
// report payload.
//
// Each test here wires a small "emitter" between an EquipmentDataModel
// and a passive HSMS Connection, drives a transition, and asserts on
// the exact frame the peer receives.
#include <doctest/doctest.h>
#include <asio.hpp>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <memory>
#include <optional>
#include <thread>
#include <utility>
#include <vector>
#include "secsgem/gem/data_model.hpp"
#include "secsgem/gem/e116_constants.hpp"
#include "secsgem/hsms/connection.hpp"
#include "secsgem/hsms/header.hpp"
#include "secsgem/secs2/codec.hpp"
#include "secsgem/secs2/message.hpp"
#include "secsgem/gem/messages.hpp" // s6f11_event_report, s16f9_pr_job_alert
using namespace secsgem;
using namespace std::chrono_literals;
namespace {
struct SocketPair {
asio::io_context io;
asio::ip::tcp::socket a{io};
asio::ip::tcp::socket b{io};
SocketPair() {
asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint(
asio::ip::address_v4::loopback(), 0));
const auto port = acc.local_endpoint().port();
bool da = false, db = false;
std::error_code ea, eb;
acc.async_accept(a, [&](std::error_code ec) { ea = ec; da = true; });
b.async_connect({asio::ip::address_v4::loopback(), port},
[&](std::error_code ec) { eb = ec; db = true; });
while (!(da && db)) {
if (io.stopped()) io.restart();
if (io.poll() == 0) std::this_thread::sleep_for(1ms);
}
REQUIRE_FALSE(ea);
REQUIRE_FALSE(eb);
}
};
template <typename Pred>
void pump_until(asio::io_context& io, Pred pred,
std::chrono::milliseconds budget = 3s) {
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();
if (io.poll() == 0) std::this_thread::sleep_for(1ms);
}
}
hsms::Timers permissive_timers() {
hsms::Timers t;
t.t3 = 5s; t.t6 = 5s; t.t7 = 5s; t.t8 = 5s; t.linktest = 0ms;
return t;
}
// Wire a SELECTED pair of HSMS Connections: equipment (passive) and
// host (active). Returns both ready-to-use.
struct WirePair {
SocketPair sp;
std::shared_ptr<hsms::Connection> equipment;
std::shared_ptr<hsms::Connection> host;
std::vector<std::pair<uint8_t, uint8_t>> host_seen; // (stream, function)
std::vector<secs2::Message> host_messages; // full bodies
WirePair() {
equipment = std::make_shared<hsms::Connection>(
std::move(sp.a), hsms::Connection::Mode::Passive, 0, permissive_timers());
host = std::make_shared<hsms::Connection>(
std::move(sp.b), hsms::Connection::Mode::Active, 0, permissive_timers());
host->set_message_handler([this](const secs2::Message& m)
-> std::optional<secs2::Message> {
host_seen.emplace_back(m.stream, m.function);
host_messages.push_back(m);
return std::nullopt;
});
bool eq_sel = false, host_sel = false;
equipment->set_selected_handler([&] { eq_sel = true; });
host->set_selected_handler([&] { host_sel = true; });
equipment->start();
host->start();
pump_until(sp.io, [&] { return eq_sel && host_sel; });
}
bool host_saw(uint8_t stream, uint8_t function) const {
for (auto& p : host_seen)
if (p.first == stream && p.second == function) return true;
return false;
}
const secs2::Message* host_last(uint8_t stream, uint8_t function) const {
for (auto it = host_messages.rbegin(); it != host_messages.rend(); ++it) {
if (it->stream == stream && it->function == function) return &*it;
}
return nullptr;
}
};
} // namespace
// ---------------- EPT / E116 -------------------------------------------------
TEST_CASE("EPT → Productive fires S6F11(kCeidProductive) carrying linked report") {
WirePair wp;
// Equipment-side model + wire wiring.
gem::EquipmentDataModel m;
m.svids.add({1001, "SVID-A", "", secs2::Item::u4(uint32_t{42})});
m.events.register_event({gem::e116::kCeidProductive, "EptProductive"});
REQUIRE(m.define_reports({{500, {1001}}}) == gem::DefineReportAck::Accept);
REQUIRE(m.link_event_reports({{gem::e116::kCeidProductive, {500}}}) ==
gem::LinkEventAck::Accept);
REQUIRE(m.enable_events(true, {gem::e116::kCeidProductive}) ==
gem::EnableEventAck::Accept);
// Hook the EPT change handler: emit S6F11 on every transition.
uint32_t dataid_counter = 1;
m.ept.set_state_change_handler([&](gem::EptState, gem::EptState to,
gem::EptEvent, std::chrono::milliseconds) {
uint32_t ceid = 0;
switch (to) {
case gem::EptState::Productive: ceid = gem::e116::kCeidProductive; break;
case gem::EptState::Standby: ceid = gem::e116::kCeidStandby; break;
default: break;
}
if (!ceid || !m.is_event_enabled(ceid)) return;
auto reports = m.compose_reports_for(ceid);
wp.equipment->send_data(
gem::s6f11_event_report(dataid_counter++, ceid, reports));
});
// Drive: NonScheduledTime → Standby → Productive.
m.ept.on_event(gem::EptEvent::EnterStandby);
m.ept.on_event(gem::EptEvent::EnterProductive);
pump_until(wp.sp.io, [&] { return wp.host_saw(6, 11); });
const auto* msg = wp.host_last(6, 11);
REQUIRE(msg);
auto parsed = gem::parse_s6f11(*msg);
REQUIRE(parsed.has_value());
CHECK(parsed->ceid == gem::e116::kCeidProductive);
REQUIRE(parsed->reports.size() == 1);
CHECK(parsed->reports[0].rptid == 500);
}
TEST_CASE("EPT: disabled CEID does NOT emit S6F11") {
WirePair wp;
gem::EquipmentDataModel m;
m.events.register_event({gem::e116::kCeidProductive, "EptProductive"});
// No define_reports / link_event_reports / enable_events.
bool emitted = false;
m.ept.set_state_change_handler([&](gem::EptState, gem::EptState to,
gem::EptEvent, std::chrono::milliseconds) {
if (to != gem::EptState::Productive) return;
if (!m.is_event_enabled(gem::e116::kCeidProductive)) return;
emitted = true;
wp.equipment->send_data(gem::s6f11_event_report(
1, gem::e116::kCeidProductive, m.compose_reports_for(gem::e116::kCeidProductive)));
});
m.ept.on_event(gem::EptEvent::EnterStandby);
m.ept.on_event(gem::EptEvent::EnterProductive);
CHECK_FALSE(emitted);
// Pump briefly to make sure no S6F11 sneaks through on a later tick.
const auto deadline = std::chrono::steady_clock::now() + 100ms;
while (std::chrono::steady_clock::now() < deadline) {
if (wp.sp.io.stopped()) wp.sp.io.restart();
wp.sp.io.poll();
std::this_thread::sleep_for(5ms);
}
CHECK_FALSE(wp.host_saw(6, 11));
}
// ---------------- E40 PRJobAlert (S16F9) -------------------------------------
TEST_CASE("PJ Queued→SettingUp fires S16F9 PRJobAlert on the wire") {
WirePair wp;
gem::EquipmentDataModel m;
m.recipes.add("RCP-1", "step1");
// Hook PJ state-change handler to fire S16F9 when alerts are enabled.
m.process_jobs.set_state_change_handler(
[&](const std::string& prjobid, gem::ProcessJobState,
gem::ProcessJobState to, gem::ProcessJobEvent trigger) {
if (trigger == gem::ProcessJobEvent::Created) return;
auto* pj = m.process_jobs.get(prjobid);
if (!pj || !pj->alert_enabled) return;
wp.equipment->send_data(
gem::s16f9_pr_job_alert(prjobid, to));
});
REQUIRE(m.process_jobs.create(
"PJ-A", "RCP-1", {"W1"},
[&](const std::string& ppid) { return m.recipes.get(ppid).has_value(); }) ==
gem::ProcessJobStore::CreateResult::Created);
REQUIRE(m.process_jobs.fire_internal("PJ-A", gem::ProcessJobEvent::Select));
pump_until(wp.sp.io, [&] { return wp.host_saw(16, 9); });
const auto* msg = wp.host_last(16, 9);
REQUIRE(msg);
REQUIRE(msg->body.has_value());
// Body: <L,2 PRJOBID PRJOBSTATE>
const auto& outer = msg->body->as_list();
REQUIRE(outer.size() == 2);
CHECK(outer[0].as_ascii() == "PJ-A");
CHECK(outer[1].as_bytes()[0] ==
static_cast<uint8_t>(gem::ProcessJobState::SettingUp));
}
TEST_CASE("PJ alert_enabled=false suppresses S16F9 emission") {
WirePair wp;
gem::EquipmentDataModel m;
m.recipes.add("RCP-1", "");
m.process_jobs.set_state_change_handler(
[&](const std::string& prjobid, gem::ProcessJobState,
gem::ProcessJobState to, gem::ProcessJobEvent trigger) {
if (trigger == gem::ProcessJobEvent::Created) return;
auto* pj = m.process_jobs.get(prjobid);
if (!pj || !pj->alert_enabled) return;
wp.equipment->send_data(
gem::s16f9_pr_job_alert(prjobid, to));
});
REQUIRE(m.process_jobs.create(
"PJ-Quiet", "RCP-1", {},
[&](const std::string& ppid) { return m.recipes.get(ppid).has_value(); }) ==
gem::ProcessJobStore::CreateResult::Created);
REQUIRE(m.process_jobs.set_alert("PJ-Quiet", false));
REQUIRE(m.process_jobs.fire_internal("PJ-Quiet", gem::ProcessJobEvent::Select));
const auto deadline = std::chrono::steady_clock::now() + 100ms;
while (std::chrono::steady_clock::now() < deadline) {
if (wp.sp.io.stopped()) wp.sp.io.restart();
wp.sp.io.poll();
std::this_thread::sleep_for(5ms);
}
CHECK_FALSE(wp.host_saw(16, 9));
}
// ---------------- E94 Control Job CEIDs --------------------------------------
TEST_CASE("CJ Executing transition fires the configured ControlJobExecuting CEID") {
static constexpr uint32_t kControlJobExecuting = 400;
WirePair wp;
gem::EquipmentDataModel m;
// events.define_reports treats an empty VID list as "delete this
// RPTID", so we register at least one SVID and reference it.
m.svids.add({2001, "STATUS", "", secs2::Item::ascii("OK")});
m.events.register_event({kControlJobExecuting, "ControlJobExecuting"});
REQUIRE(m.define_reports({{700, {2001}}}) == gem::DefineReportAck::Accept);
REQUIRE(m.link_event_reports({{kControlJobExecuting, {700}}}) ==
gem::LinkEventAck::Accept);
REQUIRE(m.enable_events(true, {kControlJobExecuting}) ==
gem::EnableEventAck::Accept);
m.control_jobs.set_state_change_handler(
[&](const std::string&, gem::ControlJobState,
gem::ControlJobState to, gem::ControlJobEvent) {
if (to != gem::ControlJobState::Executing) return;
if (!m.is_event_enabled(kControlJobExecuting)) return;
wp.equipment->send_data(gem::s6f11_event_report(
1, kControlJobExecuting, m.compose_reports_for(kControlJobExecuting)));
});
REQUIRE(m.control_jobs.create("CJ-X", {"PJ-1"},
[](const std::string&) { return true; }) ==
gem::ControlJobStore::CreateResult::Created);
REQUIRE(m.control_jobs.fire_internal("CJ-X", gem::ControlJobEvent::Select));
REQUIRE(m.control_jobs.fire_internal("CJ-X", gem::ControlJobEvent::SetupComplete));
REQUIRE(m.control_jobs.on_host_command("CJ-X", gem::ControlJobEvent::Start) ==
gem::HostCmdAck::Accept);
pump_until(wp.sp.io, [&] { return wp.host_saw(6, 11); });
const auto* msg = wp.host_last(6, 11);
REQUIRE(msg);
auto parsed = gem::parse_s6f11(*msg);
REQUIRE(parsed.has_value());
CHECK(parsed->ceid == kControlJobExecuting);
}
// ---------------- E90 Substrate processing -----------------------------------
TEST_CASE("Substrate StartProcessing fires the configured SubstrateInProcess CEID") {
static constexpr uint32_t kSubstrateInProcess = 500;
WirePair wp;
gem::EquipmentDataModel m;
m.svids.add({3001, "WAFER", "", secs2::Item::ascii("W-1")});
m.events.register_event({kSubstrateInProcess, "SubstrateInProcess"});
REQUIRE(m.define_reports({{800, {3001}}}) == gem::DefineReportAck::Accept);
REQUIRE(m.link_event_reports({{kSubstrateInProcess, {800}}}) ==
gem::LinkEventAck::Accept);
REQUIRE(m.enable_events(true, {kSubstrateInProcess}) ==
gem::EnableEventAck::Accept);
m.substrates.set_processing_handler(
[&](const std::string&, gem::SubstrateProcessingState,
gem::SubstrateProcessingState to, gem::SubstrateProcessingEvent) {
if (to != gem::SubstrateProcessingState::InProcess) return;
if (!m.is_event_enabled(kSubstrateInProcess)) return;
wp.equipment->send_data(gem::s6f11_event_report(
1, kSubstrateInProcess, m.compose_reports_for(kSubstrateInProcess)));
});
REQUIRE(m.substrates.create("W-1") ==
gem::SubstrateStore::CreateResult::Created);
REQUIRE(m.substrates.fire_processing_event(
"W-1", gem::SubstrateProcessingEvent::StartProcessing));
pump_until(wp.sp.io, [&] { return wp.host_saw(6, 11); });
const auto* msg = wp.host_last(6, 11);
REQUIRE(msg);
auto parsed = gem::parse_s6f11(*msg);
REQUIRE(parsed.has_value());
CHECK(parsed->ceid == kSubstrateInProcess);
}