#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include #include #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 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(declared), " stored ", static_cast(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(); } // 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 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); } server->Shutdown(); rt.stop(); }