912304966f
register_default_handlers was a relocated app main(): one 1086-line function, all-or-nothing. It is now 15 per-capability registration functions along the lines GEM itself defines (S1F19): identification, equipment constants, clock, event reports, remote commands, trace/limits, spooling, alarms, exceptions, material tracking (E90/E116/E157), carriers (E87), recipes, object services (E39), jobs (E40/E94), terminal services. A sensor-class tool registers three functions instead of carrying carrier/job handlers it doesn't have; register_default_handlers composes all 15. Each function derives exactly the runtime aliases its handlers use (generated programmatically from the moved bodies with boundary/substitution guards — zero hand-retyping). Magic constants are gone: the control-state/clock SVIDs (were hardcoded 1/2) and the CJ Executing/Completed CEIDs (were 400/401) now come from a "roles:" block in equipment.yaml via EquipmentDescriptor, with historical defaults when absent, loader parsing, and validation (CEID roles must name declared events). The coupling is now visible in ONE file instead of silently split between YAML and C++ — the exact drift class this repo's spec-as-data philosophy exists to kill. Tests: capability subsetting, role-driven SVID refresh via S1F3, roles loader (shipped/custom/absent). Battery: core 473/3087 incl. the 53-handler conformance sweep, daemon 125/125, live GEM300 demo (client exit 0), daemon interop 20/20 vs secsgem-py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
204 lines
7.5 KiB
C++
204 lines
7.5 KiB
C++
#include <doctest/doctest.h>
|
|
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <string>
|
|
|
|
#include "secsgem/config/loader.hpp"
|
|
#include "secsgem/secs2/item.hpp"
|
|
|
|
using namespace secsgem;
|
|
namespace gem = secsgem::gem;
|
|
namespace s2 = secsgem::secs2;
|
|
|
|
#ifndef SECSGEM_DATA_DIR
|
|
#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt"
|
|
#endif
|
|
|
|
TEST_CASE("control_state.yaml parses into the same shape as default_table()") {
|
|
auto cfg = config::load_control_state(SECSGEM_DATA_DIR "/control_state.yaml");
|
|
auto baseline = gem::ControlTransitionTable::default_table();
|
|
|
|
CHECK(cfg.initial == gem::ControlState::HostOffline);
|
|
// Same row count; same (from, on) pairs map to the same destination.
|
|
CHECK(cfg.table.size() == baseline.size());
|
|
|
|
for (const auto& r : baseline.rows()) {
|
|
const auto* loaded = cfg.table.find(r.from, r.on);
|
|
REQUIRE(loaded != nullptr);
|
|
CHECK(loaded->to == r.to);
|
|
CHECK(loaded->then == r.then);
|
|
CHECK(loaded->ack_code == r.ack_code);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, commands") {
|
|
gem::EquipmentDataModel m;
|
|
auto desc = config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
|
|
|
|
CHECK(desc.model_name == "SECSGEM-SIM");
|
|
CHECK(desc.software_rev == "0.1.0");
|
|
REQUIRE(desc.emit_on_control_change.has_value());
|
|
CHECK(*desc.emit_on_control_change == 100);
|
|
|
|
CHECK(m.svids.all().size() == 3);
|
|
CHECK(m.svids.get(1)->name == "ControlState");
|
|
CHECK(m.svids.get(3)->value == s2::Item::boolean(true));
|
|
|
|
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.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");
|
|
CHECK(m.alarms.get(1)->severity_category == 4);
|
|
|
|
CHECK(m.recipes.list().size() == 2);
|
|
CHECK(m.recipes.get("RECIPE-A").value().find("CHAMBER ARGON") != std::string::npos);
|
|
|
|
CHECK(m.commands.has("START"));
|
|
auto start = m.commands.dispatch("START", {});
|
|
CHECK(start.ack == gem::HostCmdAck::Accept);
|
|
CHECK(start.emit_ceid.value_or(0) == 300);
|
|
auto fault = m.commands.dispatch("FAULT", {});
|
|
CHECK(fault.set_alarm.value_or(0) == 1);
|
|
|
|
// Spool config loaded from YAML.
|
|
CHECK(m.spool.is_stream_spoolable(5));
|
|
CHECK(m.spool.is_stream_spoolable(6));
|
|
CHECK_FALSE(m.spool.is_stream_spoolable(1));
|
|
CHECK(m.spool.max_size() == 100);
|
|
|
|
// force_spool flag plumbed from YAML through CommandSpec.
|
|
auto on = m.commands.dispatch("SPOOL_ON", {});
|
|
auto off = m.commands.dispatch("SPOOL_OFF", {});
|
|
REQUIRE(on.force_spool.has_value());
|
|
CHECK(*on.force_spool == true);
|
|
REQUIRE(off.force_spool.has_value());
|
|
CHECK(*off.force_spool == false);
|
|
|
|
// Capabilities + equipment_type loaded for S1F20.
|
|
CHECK(desc.equipment_type == "EQUIPMENT");
|
|
REQUIRE(desc.capabilities.size() >= 5);
|
|
bool saw_spooling = false;
|
|
for (const auto& [code, name] : desc.capabilities) {
|
|
if (code == 14) saw_spooling = true;
|
|
}
|
|
CHECK(saw_spooling);
|
|
|
|
// DVIDs loaded for S1F22.
|
|
CHECK(m.dvids.all().size() == 2);
|
|
CHECK(m.dvids.get(100)->name == "WaferCounter");
|
|
CHECK(m.dvids.get(101)->units == "Torr");
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
TEST_CASE("alarm optional name: parsed when present, empty when absent") {
|
|
gem::EquipmentDataModel m;
|
|
config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
|
|
// data/equipment.yaml names both alarms.
|
|
CHECK(m.alarms.get(1)->name == "chiller_temp_high");
|
|
CHECK(m.alarms.get(2)->name == "door_open");
|
|
|
|
// A config without `name:` yields an empty name (back-compat).
|
|
const auto path = std::filesystem::temp_directory_path() / "alarm_noname.yaml";
|
|
{
|
|
std::ofstream f(path);
|
|
f << "device: {id: 0, model_name: M, software_rev: R}\n"
|
|
"alarms:\n"
|
|
" - {id: 9, text: \"Unnamed\", category: 1}\n";
|
|
}
|
|
gem::EquipmentDataModel m2;
|
|
config::load_equipment(path.string(), m2);
|
|
CHECK(m2.alarms.get(9)->name.empty());
|
|
std::filesystem::remove(path);
|
|
}
|
|
|
|
TEST_CASE("roles block: parsed when present, defaults when absent") {
|
|
gem::EquipmentDataModel m;
|
|
auto desc = config::load_equipment(SECSGEM_DATA_DIR "/equipment.yaml", m);
|
|
CHECK(desc.control_state_svid == 1);
|
|
CHECK(desc.clock_svid == 2);
|
|
CHECK(desc.cj_executing_ceid == 400);
|
|
CHECK(desc.cj_completed_ceid == 401);
|
|
|
|
const auto path = std::filesystem::temp_directory_path() / "roles_custom.yaml";
|
|
{
|
|
std::ofstream f(path);
|
|
f << "device: {id: 0, model_name: M, software_rev: R}\n"
|
|
"roles: {control_state_svid: 11, clock_svid: 12,\n"
|
|
" cj_executing_ceid: 910, cj_completed_ceid: 911}\n";
|
|
}
|
|
gem::EquipmentDataModel m2;
|
|
auto d2 = config::load_equipment(path.string(), m2);
|
|
CHECK(d2.control_state_svid == 11);
|
|
CHECK(d2.clock_svid == 12);
|
|
CHECK(d2.cj_executing_ceid == 910);
|
|
CHECK(d2.cj_completed_ceid == 911);
|
|
std::filesystem::remove(path);
|
|
|
|
const auto path2 = std::filesystem::temp_directory_path() / "roles_absent.yaml";
|
|
{
|
|
std::ofstream f(path2);
|
|
f << "device: {id: 0, model_name: M, software_rev: R}\n";
|
|
}
|
|
gem::EquipmentDataModel m3;
|
|
auto d3 = config::load_equipment(path2.string(), m3);
|
|
CHECK(d3.control_state_svid == 1); // historical defaults preserved
|
|
CHECK(d3.cj_completed_ceid == 401);
|
|
std::filesystem::remove(path2);
|
|
}
|