#include #include #include "secsgem/gem/process_job_state.hpp" #include "secsgem/gem/store/process_jobs.hpp" using namespace secsgem::gem; namespace { struct Recorder { std::vector> 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> 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)); } TEST_CASE("ProcessJobStore: add_state_change_handler observer coexists with primary") { ProcessJobStore store; int primary_calls = 0, observer_calls = 0; store.set_state_change_handler( [&](const std::string&, ProcessJobState, ProcessJobState, ProcessJobEvent) { ++primary_calls; }); store.add_state_change_handler( [&](const std::string&, ProcessJobState, ProcessJobState, ProcessJobEvent) { ++observer_calls; }); CHECK(store.create("PJ-OBS", "RECIPE-A", {"lot1"}) == ProcessJobStore::CreateResult::Created); // NoState -> Queued fires both CHECK(primary_calls == 1); CHECK(observer_calls == 1); }