hsms-gs: worked integration example + INTEGRATION.md §7

The codebase has supported HSMS-GS since the original landing
(test_hsms_gs.cpp covers the wire-level Select.req-per-session
walk-list, the per-session Reject(EntityNotSelected) behaviour,
and session-routed data dispatch).  But the documentation said
exactly one line about it ("Connection::add_session(device_id)
registers extra sessions on one TCP socket") and there was no
end-to-end test using the Server/Client API customers actually
build against.

INTEGRATION.md §7 is a new section showing the realistic pattern:

- Server-side: register the primary session via Server::Config,
  then `add_session` for the second MES in the on_connection
  callback.  Per-session message handler + selected handler so
  each MES gets its own router (or its own per-session data view
  over a shared EquipmentDataModel).
- Active-mode: same `add_session` on the host-side Connection
  for multi-tool fleet controllers.
- Equipment-initiated push: pick the session_id when sending
  unsolicited primaries (S5F1, S6F11, S10F1).
- Pointer to the wire tests + the new integration test for
  customers who want to see the failure modes.

tests/test_hsms_gs_integration.cpp drives two MES sessions
(device_id 1 + 2) through the Server/Client API end to end:
- Both sessions complete Select.req independently
- S1F1 sent on each session returns a distinct MDLN
  ("EQUIP-SESS-1" vs "EQUIP-SESS-2"), proving per-session
  dispatch routes correctly
- Per-session router fires exactly once per session, no
  cross-talk

Pre-existing §§8-10 in INTEGRATION.md got bumped to §§9-11 to
make room.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 14:56:15 +02:00
parent e3765a5176
commit b99d84f956
3 changed files with 248 additions and 4 deletions
+165
View File
@@ -0,0 +1,165 @@
// 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 <doctest/doctest.h>
#include <asio.hpp>
#include <chrono>
#include <memory>
#include <optional>
#include <string>
#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 <typename Pred>
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<void(std::error_code)> 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<Connection> equipment_conn;
int sess1_messages = 0;
int sess2_messages = 0;
server.on_connection([&](std::shared_ptr<Connection> conn) {
equipment_conn = conn;
conn->add_session(2);
conn->set_session_message_handler(
1, [&sess1_messages](const s2::Message& m)
-> std::optional<s2::Message> {
++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<s2::Message> {
++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<Connection> host_conn;
bool sess1_selected = false;
bool sess2_selected = false;
client.on_connection([&](std::shared_ptr<Connection> 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<s2::Message> 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<s2::Message> 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();
}