cf230d4119
Item 8a — ConfigValidator warns on non-identifier variable/event/alarm/ command names ([A-Za-z_][A-Za-z0-9_]*): language bindings expose names as kwargs/attributes, so 'Chamber Pressure' would be unusable in the planned Python client. Warning not error — the wire doesn't care. Tested (4 warning sites + good-name negative). Item 4 tail — golden frames for S5F1 (Binary ALCD / U4 ALID / ASCII ALTX) and a composed S6F11 (the production-critical report shape), bytes hand- computed from E5 encoding rules: external pins on message composition. Item 7 — equipment_service.hpp moved to include/secsgem/daemon/ (apps/ include-path hack removed) and a TSan daemon lane added locally + in CI. tools/tsan.supp suppresses races whose accesses sit entirely inside the UNinstrumented system libgrpc/libgpr/libabsl (epoll wakeups, absl Mutex GraphCycles bookkeeping); our frames stay fully checked. The lane earned its keep on first run: it caught a REAL threading-contract violation — a daemon test reading model stores from the test thread while the io thread serviced posted writes — fixed to use read_sync, exactly per the documented contract. Now TSan-clean under halt_on_error=1 in the full production threading shape. Suites: core 470/3068, daemon Release+TSan 125/125 each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
234 lines
7.5 KiB
C++
234 lines
7.5 KiB
C++
// 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 <doctest/doctest.h>
|
|
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <string>
|
|
|
|
#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<ConfigIssue>& 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<ConfigIssue> 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());
|
|
}
|