1da56f973f
A2 — alarms: optional 'name:' on alarm config (a LOCAL key — SEMI only
defines numeric ALID + freetext ALTX; field appended last so existing
{id, text, category} brace-inits compile unchanged), parsed by the loader,
checked by the validator, shipped in equipment.yaml. SetAlarm/ClearAlarm
RPCs resolve config name OR stringified ALID via a constructor snapshot.
A3 — control state + health: RequestControlState fires operator events on
the io thread (read_sync) and reports what the E30 table actually did —
ACCEPT iff the equipment landed in the requested state, CANNOT_DO_NOW naming
the actual state otherwise (the shipped table has no operator path to
EquipmentOffline; the test pins that honesty). ATTEMPT_ONLINE is rejected as
transient. WatchHealth streams an immediate snapshot then pushes on link/
control-state changes via service observers (add_link_observer +
add_control_state_observer — the HandlerSlot work paying off), spool depth
sampled at the 500ms poll; ends on cancel or engine stop.
Tests: daemon suite 61 -> 101 assertions (alarm lifecycle by name/id/unknown,
WatchHealth initial + change push, all four RequestControlState semantics);
loader test for the alarm name (present + absent fallback); core 467/3055.
Interop now 15 checks incl. gRPC SetAlarm -> host receives S5F1 ALCD=0x84
ALID=1, and RequestControlState(HOST_OFFLINE) -> GetControlState confirms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
287 lines
11 KiB
C++
287 lines
11 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("SetAlarm / ClearAlarm by config name, by stringified id, unknown rejected") {
|
|
auto call = [&](auto method, const std::string& name) {
|
|
grpc::ClientContext ctx;
|
|
pb::Alarm req;
|
|
pb::Ack resp;
|
|
req.set_name(name);
|
|
REQUIRE((stub.get()->*method)(&ctx, req, &resp).ok());
|
|
return resp.code();
|
|
};
|
|
// By config name.
|
|
CHECK(call(&pb::Equipment::Stub::SetAlarm, "chiller_temp_high") == pb::Ack::ACCEPT);
|
|
rt.poll();
|
|
CHECK(rt.model().alarms.active(1));
|
|
CHECK(call(&pb::Equipment::Stub::ClearAlarm, "chiller_temp_high") == pb::Ack::ACCEPT);
|
|
rt.poll();
|
|
CHECK_FALSE(rt.model().alarms.active(1));
|
|
// By stringified ALID — always works, even for unnamed alarms.
|
|
CHECK(call(&pb::Equipment::Stub::SetAlarm, "2") == pb::Ack::ACCEPT);
|
|
rt.poll();
|
|
CHECK(rt.model().alarms.active(2));
|
|
// Unknown name.
|
|
CHECK(call(&pb::Equipment::Stub::SetAlarm, "no_such_alarm") == pb::Ack::PARAMETER_INVALID);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// GetVariables needs a live io thread (read_sync posts onto it), so this case
|
|
// runs the engine in run_async() — the daemon's PRODUCTION threading mode —
|
|
// with real concurrency between the gRPC handler thread and the io thread.
|
|
TEST_CASE("GetVariables round-trip under run_async (production threading mode)") {
|
|
gem::EquipmentRuntime rt(test_config());
|
|
dmn::EquipmentService svc(rt); // snapshot BEFORE the io thread starts
|
|
rt.run_async();
|
|
|
|
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{}));
|
|
|
|
// Write through the API, then read back through the API: exercises BOTH
|
|
// conversions (Value->Item with declared formats, Item->Value) end to end.
|
|
{
|
|
grpc::ClientContext ctx;
|
|
pb::VariableUpdate req;
|
|
pb::Ack resp;
|
|
(*req.mutable_values())["ChamberPressure"].set_real(2.5);
|
|
(*req.mutable_values())["WaferCounter"].set_integer(7);
|
|
REQUIRE(stub->SetVariables(&ctx, req, &resp).ok());
|
|
REQUIRE(resp.code() == pb::Ack::ACCEPT);
|
|
}
|
|
{
|
|
grpc::ClientContext ctx;
|
|
pb::VariableQuery req;
|
|
pb::VariableSnapshot resp;
|
|
req.add_names("ChamberPressure");
|
|
req.add_names("WaferCounter");
|
|
auto st = stub->GetVariables(&ctx, req, &resp);
|
|
REQUIRE(st.ok());
|
|
REQUIRE(resp.values().count("ChamberPressure") == 1);
|
|
REQUIRE(resp.values().count("WaferCounter") == 1);
|
|
CHECK(resp.values().at("ChamberPressure").real() == doctest::Approx(2.5));
|
|
CHECK(resp.values().at("WaferCounter").integer() == 7);
|
|
}
|
|
|
|
SUBCASE("empty query returns every configured variable") {
|
|
grpc::ClientContext ctx;
|
|
pb::VariableQuery req;
|
|
pb::VariableSnapshot resp;
|
|
REQUIRE(stub->GetVariables(&ctx, req, &resp).ok());
|
|
const auto expected = rt.model().svids.size() + rt.model().dvids.all().size();
|
|
CHECK(resp.values().size() == expected);
|
|
}
|
|
|
|
SUBCASE("unknown name is INVALID_ARGUMENT, naming the offender") {
|
|
grpc::ClientContext ctx;
|
|
pb::VariableQuery req;
|
|
pb::VariableSnapshot resp;
|
|
req.add_names("definitely_not_a_var");
|
|
auto st = stub->GetVariables(&ctx, req, &resp);
|
|
CHECK(st.error_code() == grpc::StatusCode::INVALID_ARGUMENT);
|
|
CHECK(st.error_message().find("definitely_not_a_var") != std::string::npos);
|
|
}
|
|
|
|
SUBCASE("RequestControlState walks the E30 table; WatchHealth pushes the change") {
|
|
// Open the health stream first: the initial snapshot arrives immediately.
|
|
grpc::ClientContext health_ctx;
|
|
pb::Empty empty;
|
|
auto reader = stub->WatchHealth(&health_ctx, empty);
|
|
pb::Health h;
|
|
REQUIRE(reader->Read(&h));
|
|
CHECK(h.link() == pb::Health::DISCONNECTED); // no HSMS host in this test
|
|
CHECK(h.control_state() == pb::ControlState::HOST_OFFLINE);
|
|
CHECK(h.spool_depth() == 0);
|
|
|
|
auto request = [&](pb::ControlState::State desired) {
|
|
grpc::ClientContext ctx;
|
|
pb::ControlStateRequest req;
|
|
pb::Ack resp;
|
|
req.set_desired(desired);
|
|
REQUIRE(stub->RequestControlState(&ctx, req, &resp).ok());
|
|
return resp;
|
|
};
|
|
|
|
// HostOffline --operator_online--> (AttemptOnline) --> OnlineLocal.
|
|
CHECK(request(pb::ControlState::ONLINE_LOCAL).code() == pb::Ack::ACCEPT);
|
|
CHECK(rt.control_state() == gem::ControlState::OnlineLocal);
|
|
|
|
// The stream pushes the change (well inside its 500ms poll interval).
|
|
REQUIRE(reader->Read(&h));
|
|
CHECK(h.control_state() == pb::ControlState::ONLINE_LOCAL);
|
|
|
|
// OnlineLocal --operator_remote--> OnlineRemote.
|
|
CHECK(request(pb::ControlState::ONLINE_REMOTE).code() == pb::Ack::ACCEPT);
|
|
CHECK(rt.control_state() == gem::ControlState::OnlineRemote);
|
|
|
|
// Transient state is not requestable.
|
|
CHECK(request(pb::ControlState::ATTEMPT_ONLINE).code() == pb::Ack::PARAMETER_INVALID);
|
|
|
|
// The shipped table has NO operator path to EquipmentOffline:
|
|
// operator_offline lands HostOffline — the API must say so honestly.
|
|
auto resp = request(pb::ControlState::EQUIPMENT_OFFLINE);
|
|
CHECK(resp.code() == pb::Ack::CANNOT_DO_NOW);
|
|
CHECK(resp.message().find("HostOffline") != std::string::npos);
|
|
CHECK(rt.control_state() == gem::ControlState::HostOffline);
|
|
|
|
// Requesting HOST_OFFLINE (the state operators actually get) succeeds —
|
|
// idempotently, since we're already there.
|
|
CHECK(request(pb::ControlState::HOST_OFFLINE).code() == pb::Ack::ACCEPT);
|
|
|
|
health_ctx.TryCancel();
|
|
pb::Health drain;
|
|
while (reader->Read(&drain)) {}
|
|
(void)reader->Finish(); // CANCELLED — expected
|
|
}
|
|
|
|
server->Shutdown();
|
|
rt.stop();
|
|
}
|