// Multi-error config validator tests. Crafts intentionally broken // YAML, runs the validator, and asserts that the issues vector // surfaces every problem on a single pass — that's the contract the // --validate-config CLI flag relies on. #include #include #include #include #include #include #include #include "secsgem/config/validate.hpp" namespace fs = std::filesystem; using secsgem::config::ConfigIssue; using secsgem::config::ConfigValidator; namespace { fs::path scratch_path(const char* tag, const char* ext) { std::random_device rd; auto dir = fs::temp_directory_path() / (std::string("secsgem-cfg-") + tag + "-" + std::to_string(rd())); fs::create_directories(dir); return dir / (std::string("file.") + ext); } void write(const fs::path& p, const std::string& contents) { std::ofstream out(p); out << contents; } bool any_match(const std::vector& issues, const std::string& field_substr) { return std::any_of(issues.begin(), issues.end(), [&](const ConfigIssue& i) { return i.field_path.find(field_substr) != std::string::npos; }); } } // namespace TEST_CASE("Validate: equipment.yaml — clean file produces zero errors") { auto p = scratch_path("clean", "yaml"); write(p, R"YAML( device: id: 0 model_name: "TEST" software_rev: "0.1" ceids: - {id: 300, name: ProcessStarted} alarms: - {id: 1, text: "high temp", category: 4} recipes: - {id: "R-1", body: "STEP HEAT"} host_commands: - {name: START, ack: Accept, emit_ceid: 300, set_alarm: 1} )YAML"); ConfigValidator v; CHECK(v.validate_equipment(p.string())); CHECK(v.error_count() == 0); } TEST_CASE("Validate: equipment.yaml — multiple errors surface in one pass") { auto p = scratch_path("multi", "yaml"); write(p, R"YAML( device: id: 0 model_name: "TEST" # software_rev: missing svids: - {id: 1, name: ControlState, type: ASCII, value: ""} - {id: 1, name: Duplicate, type: U4, value: 0} ecids: - {id: 10, name: BadType, type: WTF, value: 1} alarms: - {id: 1, text: "x", category: 200} # out of range (max 127) host_commands: - {name: START, ack: NotARealAck, emit_ceid: 999, set_alarm: 999} emit_on_control_change: 12345 )YAML"); ConfigValidator v; CHECK_FALSE(v.validate_equipment(p.string())); const auto& issues = v.issues(); // We expect at least: missing software_rev, duplicate SVID id, // unknown type WTF, alarm category out of range, unknown HCACK, // CEID 999 not declared, ALID 999 not declared, emit_on_control_change CEID undeclared. CHECK(v.error_count() >= 8); CHECK(any_match(issues, "device.software_rev")); CHECK(any_match(issues, "svids[1].id")); CHECK(any_match(issues, "ecids[0].type")); CHECK(any_match(issues, "alarms[0].category")); CHECK(any_match(issues, "host_commands[0].ack")); CHECK(any_match(issues, "host_commands[0].emit_ceid")); CHECK(any_match(issues, "host_commands[0].set_alarm")); CHECK(any_match(issues, "emit_on_control_change")); } TEST_CASE("Validate: equipment.yaml — line numbers populate when known") { auto p = scratch_path("lines", "yaml"); write(p, R"YAML( device: id: 0 model_name: "TEST" software_rev: "0.1" alarms: - {id: 1, text: "ok", category: 4} - {id: 1, text: "dup", category: 4} )YAML"); ConfigValidator v; v.validate_equipment(p.string()); bool found_lineno = false; for (const auto& i : v.issues()) { if (i.field_path.find("alarms[1]") != std::string::npos && i.line) { found_lineno = true; // line 8 in our heredoc (the second alarm). CHECK(*i.line >= 7); CHECK(*i.line <= 9); } } CHECK(found_lineno); } TEST_CASE("Validate: control_state.yaml — unknown state name") { auto p = scratch_path("ctrl", "yaml"); write(p, R"YAML( initial: HostOffline transitions: - {from: NotAState, on: host_request_online, to: OnlineRemote, ack: Accept} - {from: HostOffline, on: not_an_event, to: HostOffline} )YAML"); ConfigValidator v; CHECK_FALSE(v.validate_control_state(p.string())); const auto& issues = v.issues(); CHECK(any_match(issues, "transitions[0].from")); CHECK(any_match(issues, "transitions[1].on")); } TEST_CASE("Validate: process_job_state.yaml — NoState sentinel rejected") { auto p = scratch_path("pj", "yaml"); write(p, R"YAML( initial: NoState transitions: - {from: NoState, on: select, to: SettingUp} - {from: Queued, on: select, to: NoState} )YAML"); ConfigValidator v; CHECK_FALSE(v.validate_process_job_state(p.string())); // All three NoState placements should each surface as an error. std::size_t no_state_hits = 0; for (const auto& i : v.issues()) { if (i.message.find("NoState") != std::string::npos) ++no_state_hits; } CHECK(no_state_hits == 3); } TEST_CASE("Validate: control_job_state.yaml — clean file passes") { auto p = scratch_path("cj-clean", "yaml"); // State names are CamelCase, event names are snake_case — the // convention the runtime parsers in src/gem/control_job_state.cpp // enforce. write(p, R"YAML( initial: Queued transitions: - {from: Queued, on: select, to: Selected, ack: Accept} - {from: Selected, on: start, to: Executing} )YAML"); ConfigValidator v; CHECK(v.validate_control_job_state(p.string())); CHECK(v.error_count() == 0); } TEST_CASE("Validate: format_issues_to renders error and warning rows") { std::vector issues = { {"a.yaml", 12, "device.id", "out of range", ConfigIssue::Severity::Error}, {"b.yaml", std::nullopt, "spool.max_size", "consider raising", ConfigIssue::Severity::Warning}, }; std::ostringstream os; secsgem::config::format_issues_to(os, issues); const auto out = os.str(); CHECK(out.find("[error] a.yaml:12 device.id") != std::string::npos); CHECK(out.find("[warn] b.yaml spool.max_size") != std::string::npos); } TEST_CASE("Validate: ships data/equipment.yaml without errors") { // The shipped demo config is the canonical "good" sample; if anyone // edits data/equipment.yaml in a way that breaks the validator's // invariants, this test fails loudly. ConfigValidator v; v.validate_equipment(SECSGEM_DATA_DIR "/equipment.yaml"); v.validate_control_state(SECSGEM_DATA_DIR "/control_state.yaml"); v.validate_process_job_state(SECSGEM_DATA_DIR "/process_job_state.yaml"); v.validate_control_job_state(SECSGEM_DATA_DIR "/control_job_state.yaml"); if (v.has_errors()) { std::ostringstream os; secsgem::config::format_issues_to(os, v.issues()); FAIL(os.str()); } CHECK(v.error_count() == 0); } TEST_CASE("Validate: non-identifier names warn (bindings expose names as kwargs)") { auto p = scratch_path("idname", "yaml"); write(p, R"YAML( device: id: 0 model_name: "TEST" software_rev: "1.0" svids: - {id: 1, name: "Chamber Pressure", type: F4, value: 0.0} - {id: 2, name: GoodName, type: U4, value: 0} ceids: - {id: 10, name: "wafer-complete"} alarms: - {id: 1, name: "2bad", text: "T", category: 1} host_commands: - {name: "DO IT", ack: Accept} )YAML"); ConfigValidator v; v.validate_equipment(p.string()); CHECK(v.error_count() == 0); // identifier shape is a WARNING, not an error CHECK(v.warning_count() == 4); // space, hyphen, leading digit, space CHECK(any_match(v.issues(), "svids[0].name")); CHECK(any_match(v.issues(), "ceids[0].name")); CHECK(any_match(v.issues(), "alarms[0].name")); CHECK(any_match(v.issues(), "host_commands[0].name")); CHECK_FALSE(any_match(v.issues(), "svids[1].name")); fs::remove_all(p.parent_path()); }