// HSMS-GS integration test: drives a passive equipment with TWO MES // sessions (device_id 1 + 2) over a single TCP connection through the // Server/Client high-level API customers actually use. Each MES gets // its own router + selected handler + send path. // // This is the "two MES, one tool" pattern that the codebase advertises // in COMPLIANCE.md §1 ("HSMS-GS (general-session) ✅") and that // INTEGRATION.md §7 mentions. Until now there was no end-to-end test // covering the Server/Client integration — only direct Connection // wire tests in test_hsms_gs.cpp. #include #include #include #include #include #include #include "secsgem/endpoint.hpp" #include "secsgem/gem/messages.hpp" #include "secsgem/secs2/message.hpp" using namespace secsgem; using namespace std::chrono_literals; namespace s2 = secsgem::secs2; namespace gem = secsgem::gem; namespace { template void run_until(asio::io_context& io, Pred pred, std::chrono::seconds budget = 10s) { asio::steady_timer cap(io); cap.expires_after(budget); cap.async_wait([&io](std::error_code ec) { if (!ec) io.stop(); }); asio::steady_timer poll(io); std::function tick = [&](std::error_code ec) { if (ec) return; if (pred()) { io.stop(); return; } poll.expires_after(1ms); poll.async_wait(tick); }; poll.expires_after(1ms); poll.async_wait(tick); io.run(); io.restart(); } } // namespace TEST_CASE("HSMS-GS integration: two MES sessions on one Server, distinct dispatch") { asio::io_context io; // Pick an OS-allocated port for the test. asio::ip::tcp::acceptor probe( io, asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), 0)); const auto port = probe.local_endpoint().port(); probe.close(); // Equipment: passive Server bound to the primary session (device_id=1) // by construction, then add_session(2) for the second MES. Each // session has its own router (different MDLN/SOFTREV so we can tell // them apart on the wire). Server::Config sc; sc.port = port; sc.device_id = 1; Server server(io, sc); std::shared_ptr equipment_conn; int sess1_messages = 0; int sess2_messages = 0; server.on_connection([&](std::shared_ptr conn) { equipment_conn = conn; conn->add_session(2); conn->set_session_message_handler( 1, [&sess1_messages](const s2::Message& m) -> std::optional { ++sess1_messages; if (m.stream == 1 && m.function == 1) return gem::s1f2_on_line_data("EQUIP-SESS-1", "1.0"); return std::nullopt; }); conn->set_session_message_handler( 2, [&sess2_messages](const s2::Message& m) -> std::optional { ++sess2_messages; if (m.stream == 1 && m.function == 1) return gem::s1f2_on_line_data("EQUIP-SESS-2", "2.0"); return std::nullopt; }); }); server.start(); // Active host running TWO sessions over a single TCP connection. // Mirrors the multi-MES "one tool, two factory schedulers" pattern. Client::Config cc; cc.host = "127.0.0.1"; cc.port = port; cc.device_id = 1; cc.timers.linktest = 0ms; Client client(io, cc); std::shared_ptr host_conn; bool sess1_selected = false; bool sess2_selected = false; client.on_connection([&](std::shared_ptr conn) { host_conn = conn; conn->add_session(2); conn->set_session_selected_handler(1, [&] { sess1_selected = true; }); conn->set_session_selected_handler(2, [&] { sess2_selected = true; }); }); client.start(); // Both sessions go through Select.req individually (Active mode // walk-list: session 1 first, then session 2 once 1 is SELECTED). run_until(io, [&] { return sess1_selected && sess2_selected; }); REQUIRE(sess1_selected); REQUIRE(sess2_selected); REQUIRE(equipment_conn); REQUIRE(host_conn); // Host sends S1F1 on session 1 → expects MDLN "EQUIP-SESS-1". std::optional sess1_reply; host_conn->send_request(1, s2::Message(1, 1, true), [&](std::error_code ec, const s2::Message& m) { if (!ec) sess1_reply = m; }); // Host sends S1F1 on session 2 → expects MDLN "EQUIP-SESS-2". std::optional sess2_reply; host_conn->send_request(2, s2::Message(1, 1, true), [&](std::error_code ec, const s2::Message& m) { if (!ec) sess2_reply = m; }); run_until(io, [&] { return sess1_reply.has_value() && sess2_reply.has_value(); }); REQUIRE(sess1_reply.has_value()); REQUIRE(sess2_reply.has_value()); CHECK(sess1_reply->stream == 1); CHECK(sess1_reply->function == 2); CHECK(sess2_reply->stream == 1); CHECK(sess2_reply->function == 2); // Both routers fired, each on its own session — no cross-talk. CHECK(sess1_messages == 1); CHECK(sess2_messages == 1); // The replies have different MDLNs proving the per-session // dispatch returned distinct payloads. const auto sml1 = sess1_reply->sml(); const auto sml2 = sess2_reply->sml(); CHECK(sml1.find("EQUIP-SESS-1") != std::string::npos); CHECK(sml2.find("EQUIP-SESS-2") != std::string::npos); host_conn->separate(); }