diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f1d7e9..38477f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,6 +127,7 @@ add_executable(secsgem_tests tests/test_s9_fallback.cpp tests/test_e84.cpp tests/test_gem300_scenario.cpp + tests/test_wire_ceid_emission.cpp ) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_compile_definitions(secsgem_tests PRIVATE diff --git a/tests/test_wire_ceid_emission.cpp b/tests/test_wire_ceid_emission.cpp new file mode 100644 index 0000000..8a981ff --- /dev/null +++ b/tests/test_wire_ceid_emission.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 +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 equipment; + std::shared_ptr host; + std::vector> host_seen; // (stream, function) + std::vector host_messages; // full bodies + + WirePair() { + equipment = std::make_shared( + std::move(sp.a), hsms::Connection::Mode::Passive, 0, permissive_timers()); + host = std::make_shared( + std::move(sp.b), hsms::Connection::Mode::Active, 0, permissive_timers()); + + host->set_message_handler([this](const secs2::Message& m) + -> std::optional { + 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: + 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(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); +}