Files
secs-gem/tests/test_daemon_service.cpp
raphael 42044e92e2
tests / build-and-test (push) Successful in 2m42s
tests / thread-sanitizer (push) Successful in 2m50s
tests / tshark-dissector (push) Successful in 2m24s
tests / secs4j-interop (push) Successful in 37s
tests / python-interop (push) Successful in 2m56s
tests / libfuzzer (push) Successful in 3m44s
ci(interop): one-command external-validation suite + CI lanes for the daemon
tools/run_interop.sh runs ALL nine validation steps with a PASS/FAIL summary:
build, unit (464), daemon-unit (41), secsgem-py host vs server (31 checks),
secs_conformance (47), gRPC+secsgem-py daemon bridge, spool persistence
across restart, tshark HSMS dissector, secs4java8 (55 checks). Verified green
end-to-end. The unit suite is partly self-referential (our parsers validate
our builders); these external validators are the real oracle — now they run
with one command instead of by hand. Two bugs found by running it: unbounded
ninja at -O3 OOM-kills cc1plus in memory-constrained Docker VMs (build with
-j 2) and bash-3.2 lacks negative array subscripts.

CI: grpc deps added to the build job so secs_gemd + secs_gemd_tests build and
RUN in CI (previously the daemon silently dropped out — now fails loudly if
missing), plus a python-interop lane running py-host/conformance/daemon
harnesses against localhost in one container (no docker-in-docker).

Service hardening while in there: reject proto Values with no kind set at
the RPC edge (previously silently became ASCII ""), TODO markers for list
element formats and daemon graceful shutdown. New tests: unset-Value guard
+ a property test iterating ALL configured variables via gRPC asserting each
keeps its declared SECS-II format (daemon tests 16 -> 41 assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:08:37 +02:00

149 lines
5.4 KiB
C++

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <grpcpp/grpcpp.h>
#include "equipment_service.hpp"
using namespace secsgem;
namespace gem = secsgem::gem;
namespace s2 = secsgem::secs2;
namespace pb = secsgem::v1;
namespace dmn = secsgem::daemon;
#ifndef SECSGEM_DATA_DIR
#error "SECSGEM_DATA_DIR not defined; see CMakeLists.txt"
#endif
static gem::EquipmentRuntime::Config test_config() {
gem::EquipmentRuntime::Config c;
c.equipment_yaml = SECSGEM_DATA_DIR "/equipment.yaml";
c.control_state_yaml = SECSGEM_DATA_DIR "/control_state.yaml";
c.process_job_yaml = SECSGEM_DATA_DIR "/process_job_state.yaml";
c.control_job_yaml = SECSGEM_DATA_DIR "/control_job_state.yaml";
c.port = 0; // ephemeral; the engine isn't run() here, only poll()ed
return c;
}
// Exercises the real gRPC service over an in-process channel: client stub ->
// service -> EquipmentRuntime, proving the RPCs move data, not just compile.
TEST_CASE("Equipment gRPC service over an in-process channel") {
gem::EquipmentRuntime rt(test_config());
dmn::EquipmentService svc(rt);
grpc::ServerBuilder builder;
builder.RegisterService(&svc);
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
REQUIRE(server);
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
SUBCASE("GetControlState returns the initial control state") {
grpc::ClientContext ctx;
pb::Empty req;
pb::ControlState resp;
auto st = stub->GetControlState(&ctx, req, &resp);
CHECK(st.ok());
CHECK(resp.state() == pb::ControlState::HOST_OFFLINE);
}
SUBCASE("SetVariables converts to the variable's declared wire format") {
// ChamberPressure is declared F4 and WaferCounter U4 in equipment.yaml;
// the daemon must honour those, not write F8/I8, or the host sees values
// whose format contradicts the S1F11/S1F21 namelists.
grpc::ClientContext ctx;
pb::VariableUpdate req;
pb::Ack resp;
(*req.mutable_values())["ChamberPressure"].set_real(2.5);
(*req.mutable_values())["WaferCounter"].set_integer(7);
auto st = stub->SetVariables(&ctx, req, &resp);
CHECK(st.ok());
CHECK(resp.code() == pb::Ack::ACCEPT);
rt.poll(); // drain the posted set_variable onto this thread
CHECK(rt.model().dvids.value(101) == s2::Item::f4(2.5f));
CHECK(rt.model().dvids.value(100) == s2::Item::u4(uint32_t{7}));
}
SUBCASE("SetVariables rejects an unknown variable name") {
grpc::ClientContext ctx;
pb::VariableUpdate req;
pb::Ack resp;
(*req.mutable_values())["definitely_not_a_var"].set_real(1.0);
auto st = stub->SetVariables(&ctx, req, &resp);
CHECK(st.ok());
CHECK(resp.code() == pb::Ack::PARAMETER_INVALID);
}
SUBCASE("SetVariables rejects a Value with no kind set (silent-'' guard)") {
grpc::ClientContext ctx;
pb::VariableUpdate req;
pb::Ack resp;
(*req.mutable_values())["ChamberPressure"]; // map entry, kind never set
auto st = stub->SetVariables(&ctx, req, &resp);
CHECK(st.ok());
CHECK(resp.code() == pb::Ack::PARAMETER_INVALID);
// The previous value must be untouched (no silent ASCII "" write).
rt.poll();
CHECK(rt.model().dvids.value(101)->format() == s2::Format::F4);
}
SUBCASE("property: every configured variable keeps its declared format") {
// Iterate ALL SVIDs+DVIDs from the live config: set each via gRPC with a
// type-appropriate plain value and assert the stored Item keeps the
// declared wire format. Catches any future variable whose format the
// conversion table doesn't handle — not just the two pinned above.
auto check_all = [&](const auto& entries) {
for (const auto& v : entries) {
const auto declared = v.value.format();
grpc::ClientContext ctx;
pb::VariableUpdate req;
pb::Ack resp;
pb::Value val;
switch (declared) {
case s2::Format::ASCII: case s2::Format::JIS8:
val.set_text("x"); break;
case s2::Format::Boolean:
val.set_boolean(true); break;
case s2::Format::Binary:
val.set_binary("\x01"); break;
case s2::Format::F4: case s2::Format::F8:
val.set_real(1.5); break;
default: // all integer widths
val.set_integer(1); break;
}
(*req.mutable_values())[v.name] = val;
REQUIRE(stub->SetVariables(&ctx, req, &resp).ok());
REQUIRE_MESSAGE(resp.code() == pb::Ack::ACCEPT, v.name);
rt.poll();
auto stored = rt.model().vid_value(v.id);
REQUIRE_MESSAGE(stored.has_value(), v.name);
CHECK_MESSAGE(stored->format() == declared,
v.name, ": declared ", static_cast<int>(declared),
" stored ", static_cast<int>(stored->format()));
}
};
check_all(rt.model().svids.all());
check_all(rt.model().dvids.all());
}
SUBCASE("FireEvent accepts a known event and rejects an unknown one") {
{
grpc::ClientContext ctx;
pb::Event req;
pb::Ack resp;
req.set_name("ProcessStarted");
CHECK(stub->FireEvent(&ctx, req, &resp).ok());
CHECK(resp.code() == pb::Ack::ACCEPT);
}
{
grpc::ClientContext ctx;
pb::Event req;
pb::Ack resp;
req.set_name("NoSuchEvent");
CHECK(stub->FireEvent(&ctx, req, &resp).ok());
CHECK(resp.code() == pb::Ack::PARAMETER_INVALID);
}
}
server->Shutdown();
}