From cf9ce91e5157b312d61f4348275240f26359698d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 18:33:46 -0400 Subject: [PATCH] test(mtconnect): live-Agent docker fixture + integration suite (Tasks 19+20) The first and only thing in the MTConnect workstream that exercises the driver against a real Agent; everything else runs on canned XML. Fixture (Docker/): two services, both stock images with this folder bind-mounted. - `agent` mtconnect/agent:2.7.0.12 on :5000. NOTE the repo name: the source project is mtconnect/cppagent but the PUBLISHED image is mtconnect/agent; mtconnect/cppagent does not exist on Docker Hub. - `adapter` a stdlib SHDR feeder. Required, not optional: the agent image ships only the binary + schemas/styles, so a lone Agent answers /probe and then reports every observation UNAVAILABLE forever, which can prove nothing about reads or streaming. Devices.xml seeds one DataItem per inference branch (SAMPLE+units, TIME_SERIES, PART_COUNT/LINE_NUMBER, controlled vocab, DATA_SET, CONDITION), gives every named item `name != id` (the inverse of the hand-authored unit fixtures), and leaves one EVENT permanently unfed so UNAVAILABLE -> BadNoCommunication has a live subject. Suite (12 tests) asserts STRUCTURALLY against the Agent's own /probe response, never by literal id, so it survives an edit to Devices.xml and can be pointed at a real machine tool via MTCONNECT_AGENT_ENDPOINT. It skips cleanly (12/12) when no Agent answers, and each test carries a hard [Fact(Timeout)] so a half-up fixture cannot wedge a build. Three findings from bringing the fixture up, each now pinned in a comment: - agent.cfg must be pure ASCII; one non-ASCII byte in a COMMENT makes the config parser reject the whole file with a bare "Failed / Stopped at line: N" and exit. - `sampleCount` is an attribute of a TIME_SERIES observation, not of a DataItem declaration. A real Agent meeting one on a declaration DROPS THE ENTIRE DATA ITEM from the device model, so the inference only ever sees null and a live TIME_SERIES tag is always variable-length. Asserted, not ignored. - The Agent's own self-model publishes update-rate SAMPLEs that tick whether or not any adapter is attached. Excluding them is load-bearing: with them included the "live stream delivered a changed value" test passed with the fixture's data source deliberately stopped. MTConnectError-under-HTTP-200 is NOT covered live and cannot be: cppagent 2.7 answers an unknown device 404 and an out-of-range sequence 400, each with a well-formed error body. That shape stays canned-only, and the suite says so. --- ZB.MOM.WW.OtOpcUa.slnx | 1 + infra/README.md | 12 +- .../DiscoveryCapture.cs | 110 +++ .../Docker/Devices.xml | 112 +++ .../Docker/README.md | 105 +++ .../Docker/adapter.py | 229 ++++++ .../Docker/agent.cfg | 50 ++ .../Docker/docker-compose.yml | 79 ++ .../MTConnectAgentFixture.cs | 190 +++++ .../MTConnectAgentIntegrationTests.cs | 697 ++++++++++++++++++ ...a.Driver.MTConnect.IntegrationTests.csproj | 41 ++ 11 files changed, 1624 insertions(+), 2 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 94638260..4e577d11 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -102,6 +102,7 @@ + diff --git a/infra/README.md b/infra/README.md index 1edd576d..a22f6233 100644 --- a/infra/README.md +++ b/infra/README.md @@ -37,7 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{ | Stack dir | Purpose | |---|---| | `/opt/otopcua-mssql` | central SQL (always-on) | - | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` | driver fixtures | + | `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` · `/opt/otopcua-mtconnect` | driver fixtures | | `~/otopcua-ablegacy` · `~/otopcua-focas` | driver fixtures (user-owned) | | `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 | @@ -71,10 +71,11 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live | FOCAS | `otopcua-focas-sim` (vendored focas-mock) | `10.100.0.35:8193` | `docker compose up -d` (stack `~/otopcua-focas`) | | MQTT | `eclipse-mosquitto:2.0.22` (+ a publisher sidecar) | `10.100.0.35:8883` TLS+auth · `:1883` plaintext+auth | `MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD= docker compose up -d` (stack `/opt/otopcua-mqtt`) | | MQTT — Sparkplug B | `otopcua-sparkplug-sim` (project-owned C# edge-node simulator) | same broker; group **`OtOpcUaSim`**, edge nodes **`EdgeA`** / **`EdgeB`**, `EdgeA` device **`Filler1`**, ~2 s cadence | `docker compose --profile sparkplug up -d` (same stack). Answers rebirth NCMDs; restart it to force a fresh birth | +| MTConnect | `mtconnect/agent:2.7.0.12` + `python:3.13-alpine` adapter | `http://10.100.0.35:5000` | `docker compose up -d --wait` | **Endpoint overrides** point a suite at a real PLC instead of the sim: `MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` · -`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT`. +`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT` · `MTCONNECT_AGENT_ENDPOINT`. > **MQTT needs credentials + material, unlike the other fixtures.** The broker has **no anonymous > fallback on either listener** (deliberate — an anonymous broker would let a bad auth config pass), @@ -84,6 +85,13 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live > copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`. > It is also the only fixture whose services carry the `project: lmxopcua` label (see CLAUDE.md). +> **MTConnect is a TWO-service stack.** The `mtconnect/agent` image ships no simulator, so a lone +> Agent answers `/probe` and reports every observation `UNAVAILABLE` forever. The `adapter` service +> (a stdlib SHDR feeder, `Docker/adapter.py`) is the data source; the Agent dials out to it. Its +> suite also probes over **HTTP**, not TCP — an Agent's port accepts connections before its device +> model is parsed. Full detail + the seeded device model: +> [`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md). + > **Fixture-cycling gotcha:** profile-gated services can share a port; a plain `docker compose down` > may leave a stale container bound. Force-remove before switching profiles: > `docker rm -f $(docker ps -aq --filter name=otopcua-)` then `up --force-recreate`. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs new file mode 100644 index 00000000..d40b4662 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs @@ -0,0 +1,110 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests; + +/// +/// An that records the tree DiscoverAsync streams into +/// it, so discovery against a live Agent can be asserted on shape (which leaf landed under which +/// folder, with which driver-side metadata) rather than on a flat list. +/// +/// +/// A near-twin of the MTConnect unit suite's CapturingBuilder, restated here rather than +/// shared: that type is internal to a different test assembly, and the two production +/// capturing builders (Commons.Browsing / Runtime.Drivers) would drag the server +/// stack into a driver integration project that deliberately references only the driver. +/// +internal sealed class DiscoveryCapture : IAddressSpaceBuilder +{ + private readonly State _state; + private readonly string _path; + + /// Creates the root scope a driver's DiscoverAsync is handed. + public DiscoveryCapture() + { + _state = new State(); + _path = string.Empty; + } + + private DiscoveryCapture(State state, string path) + { + _state = state; + _path = path; + } + + /// Every folder streamed, with the slash-joined path it landed at. + public IReadOnlyList Folders => _state.Folders; + + /// Every variable streamed, with the folder path it landed under. + public IReadOnlyList Variables => _state.Variables; + + /// + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + var path = _path.Length == 0 ? browseName : $"{_path}/{browseName}"; + _state.Folders.Add(new CapturedFolder(path, _path, browseName, displayName)); + + return new DiscoveryCapture(_state, path); + } + + /// + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + var captured = new CapturedVariable(_path, browseName, displayName, attributeInfo); + _state.Variables.Add(captured); + + return new Handle(captured); + } + + /// + public void AddProperty(string browseName, DriverDataType dataType, object? value) + { + // Recorded nowhere: the MTConnect driver streams no node properties, and a capture that + // threw here would fail a driver that legitimately started to. + } + + private sealed class State + { + public List Folders { get; } = []; + + public List Variables { get; } = []; + } + + private sealed class Handle(CapturedVariable variable) : IVariableHandle + { + public string FullReference => variable.Attr.FullName; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) + { + variable.AlarmConditions.Add(info); + + return new NullSink(); + } + + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) + { + // No test here drives an alarm transition through discovery. + } + } + } +} + +/// One folder captured from a discovery stream. +/// Slash-joined path of the folder itself. +/// Slash-joined path of the scope it was added to (empty at the root). +/// The browse name the driver supplied. +/// The display name the driver supplied. +internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName); + +/// One variable captured from a discovery stream. +/// Slash-joined path of the folder it landed under. +/// The browse name the driver supplied. +/// The display name the driver supplied. +/// The driver-side attribute metadata the driver stamped on it. +internal sealed record CapturedVariable( + string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr) +{ + /// Alarm conditions the driver marked this variable with, if any. + public List AlarmConditions { get; } = []; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml new file mode 100644 index 00000000..19979d1a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml @@ -0,0 +1,112 @@ + + + +
+ + + + MTConnect integration-test fixture + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md new file mode 100644 index 00000000..d9fa1e8c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md @@ -0,0 +1,105 @@ +# MTConnect integration-test fixture — the official C++ Agent + an SHDR data source + +The MTConnect C++ Agent (`mtconnect/agent`, pinned) serving a canned device model, fed live +data by a standard-library SHDR adapter. No image build step: both services run stock images +with this folder's files bind-mounted. + +> **The published image is `mtconnect/agent`, not `mtconnect/cppagent`.** `cppagent` is the +> name of the source project on GitHub; there is no Docker Hub repository under that name. + +| File | Purpose | +|---|---| +| [`docker-compose.yml`](docker-compose.yml) | Two services: `agent` (published on :5000) and `adapter` (internal, :7878) | +| [`agent.cfg`](agent.cfg) | Agent configuration, bind-mounted at `/mtconnect/config/agent.cfg` (the image's CMD path) | +| [`Devices.xml`](Devices.xml) | The canned device model the Agent serves from `/probe` | +| [`adapter.py`](adapter.py) | SHDR feeder — the data source. Pure stdlib, runs on a stock `python:*-alpine` | + +## Why there is an adapter service + +The `mtconnect/agent` image ships **only** the agent binary plus its schemas and styles — there +is no bundled simulator. An Agent with no adapter answers `/probe` correctly and then reports +**every observation `UNAVAILABLE` forever**, which cannot prove that reads return real values or +that the `/sample` long poll delivers anything. `adapter.py` is what makes the fixture live. + +The Agent dials **out** to the adapter (see the `Adapters` block in `agent.cfg`); the adapter is +not published to the host. + +## Run + +From the shared Docker host (stack dir `/opt/otopcua-mtconnect`): + +```bash +docker compose up -d --wait +docker compose logs -f agent +docker compose down +``` + +From a dev box via the helper (see CLAUDE.md "Docker Workflow"): + +```powershell +lmxopcua-fix sync mtconnect # push this folder to /opt/otopcua-mtconnect/ +lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument +lmxopcua-fix logs mtconnect +lmxopcua-fix down mtconnect +``` + +### Running it on a Mac + +macOS **squats port 5000** — AirPlay Receiver (ControlCenter) binds `*:5000` and wins the race, +so `docker port` reports a healthy publish while every request answers `403 Forbidden` with +`Server: AirTunes`. Use the host-port override: + +```bash +MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait +MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests +``` + +## Endpoint + +- Default: `http://10.100.0.35:5000` (the shared Docker host; 5000 is the Agent's own default port). +- Override with `MTCONNECT_AGENT_ENDPOINT` to point at a real Agent on a machine tool. +- `MTCONNECT_AGENT_HOST_PORT` changes only the **published host port** of the fixture container. + +`MTConnectAgentFixture` issues one `GET {endpoint}/probe` at collection init and records a +`SkipReason` when it fails, so the suite skips cleanly on a box with no fixture running. + +## The seeded device model + +Every named DataItem deliberately has `name != id` — the inverse of the driver's hand-authored +unit fixtures, and the only arrangement under which confusing the browse name with the +observation correlation key is visible. + +| DataItem `id` | `name` | Category | Type | Repr. | Inferred type | +|---|---|---|---|---|---| +| `fixture_avail` | `Favail` | EVENT | AVAILABILITY | | String | +| `fixture_x_pos` | `Xact` | SAMPLE | POSITION (ACTUAL, MILLIMETER) | | Float64 — **moves** | +| `fixture_x_load` | `Xload` | SAMPLE | LOAD (PERCENT) | | Float64 — **moves** | +| `fixture_x_travel` | `Xtravel` | CONDITION | POSITION | | String + IsAlarm | +| `fixture_c_speed` | `Cspeed` | SAMPLE | ROTARY_VELOCITY (REVOLUTION/MINUTE) | | Float64 — **moves** | +| `fixture_c_temp_series` | `Ctemps` | SAMPLE | TEMPERATURE (CELSIUS) | TIME_SERIES | Float64 **array**, ArrayDim `null` | +| `fixture_mode` | `Cmode` | EVENT | CONTROLLER_MODE | | String | +| `fixture_execution` | `Pexec` | EVENT | EXECUTION | | String | +| `fixture_partcount` | `Pcount` | EVENT | **PART_COUNT** | | **Int64** — the regression case | +| `fixture_linenumber` | `Pline` | EVENT | LINE_NUMBER | | Int64 | +| `fixture_program` | `Pprogram` | EVENT | PROGRAM | | String | +| `fixture_block` | `Pblock` | EVENT | BLOCK | | String — **never fed ⇒ UNAVAILABLE** | +| `fixture_varset` | `Pvars` | EVENT | VARIABLE | DATA_SET | String (structured ⇒ BadNotSupported) | +| `fixture_logic` | `Plogic` | CONDITION | LOGIC_PROGRAM | | String + IsAlarm | +| `fixture_asset_changed` / `fixture_asset_removed` | — | EVENT | ASSET_CHANGED / ASSET_REMOVED | | String | + +The Agent additionally injects **its own `` self-model device** (connection status, +observation update rate, adapter URI). That is normal for every MTConnect 2.x Agent and the test +suite excludes it from value-plane assertions — its update-rate samples tick whether or not any +adapter is attached, so including them would let "the stream delivered a changed value" pass +against an Agent with no data source at all. + +## Two things a real Agent taught us (both cost a fixture restart to find) + +1. **`agent.cfg` must be pure ASCII.** A single non-ASCII byte — even inside a comment — makes + the config parser reject the whole file with a bare `Failed / Stopped at line: N` and exit. +2. **`sampleCount` is not a DataItem attribute.** It belongs to a TIME_SERIES *observation*. An + Agent that meets it on a declaration logs + `The following keys were present and not expected: sampleCount` followed by + `DataItems: Invalid element 'DataItem'` and **drops the entire data item** from the device + model. `MTConnectDataTypeInference` therefore only ever sees `sampleCount = null` from a real + Agent, so a live TIME_SERIES tag is always a variable-length array. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py new file mode 100644 index 00000000..06019cc3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""SHDR adapter feeding the OtOpcUa MTConnect integration-test fixture live data. + +The mtconnect/agent image ships *only* the agent binary plus its schemas and styles -- +there is no bundled simulator. Without an adapter the Agent answers /probe correctly but +reports every observation UNAVAILABLE forever, which cannot prove the two things this +fixture exists for: that ReadAsync returns real coerced values, and that the driver's +/sample long poll delivers OnDataChange from a genuinely moving stream. + +So this is the data source. It speaks SHDR (the Agent's plain-text adapter protocol) over +TCP: the Agent dials IN as the client (see agent.cfg's Adapters block), and every line we +write is `||[||...]`, where matches a DataItem's +`name` attribute in Devices.xml (falling back to its `id` for the unnamed ones). + +Deliberate behaviours -- each one is asserted on by the integration suite: + + * Xact / Xload / Cspeed move on EVERY tick. "The value changed between two reads" is the + only assertion that distinguishes a live stream from a cached /current snapshot. + * Pcount (PART_COUNT) increments on a slower cadence, so it is both a *changing* value + and an integer -- the live half of the UPPER_SNAKE PART_COUNT -> Int64 inference. + * Pblock is NEVER written. The Agent therefore reports it UNAVAILABLE for the life of + the fixture, which is what exercises UNAVAILABLE -> BadNoCommunication. + * Conditions are re-asserted periodically rather than once at connect, so a driver that + attaches mid-run still sees them. + +Pure standard library on purpose: the container is a stock `python:*-alpine` with this +file bind-mounted, so the fixture needs no image build step at all (unlike the Modbus / +S7 fixtures, whose simulators do). +""" + +from __future__ import annotations + +import argparse +import datetime +import math +import selectors +import socket +import sys +import threading +import time + +# How often the periodic feed writes a batch of observations. Comfortably faster than the +# driver's default SampleIntervalMs (1000) so a subscription sees several distinct chunks +# inside a short test timeout. +TICK_SECONDS = 0.25 + +# The Agent's PING/PONG watchdog: it sends `* PING` and expects `* PONG ` back. The +# number is how long the Agent should wait before declaring us dead. +PONG_TIMEOUT_MS = 10000 + +EXECUTION_STATES = ("ACTIVE", "READY", "INTERRUPTED", "ACTIVE", "STOPPED") +CONTROLLER_MODES = ("AUTOMATIC", "MANUAL", "AUTOMATIC", "SEMI_AUTOMATIC") + + +def timestamp() -> str: + """UTC in the ISO-8601 form the Agent expects (milliseconds, trailing Z).""" + now = datetime.datetime.now(datetime.timezone.utc) + + return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z" + + +class Feed: + """One connected Agent. Owns the socket for the life of that connection.""" + + def __init__(self, conn: socket.socket, peer: str) -> None: + self._conn = conn + self._peer = peer + self._started = time.monotonic() + self._ticks = 0 + + # ---- wire ---- + + def send(self, line: str) -> None: + self._conn.sendall((line + "\n").encode("utf-8")) + + def send_observations(self, pairs: list[tuple[str, str]]) -> None: + """One SHDR line carrying several key/value pairs, all sharing one timestamp.""" + if not pairs: + return + + body = "|".join(f"{key}|{value}" for key, value in pairs) + self.send(f"{timestamp()}|{body}") + + # ---- content ---- + + def send_initial_state(self) -> None: + """Everything that is not periodic, sent once as soon as the Agent attaches.""" + self.send_observations( + [ + ("Favail", "AVAILABLE"), + ("Pprogram", "OTOPCUA-FIXTURE.NC"), + # DATA_SET representation: the Agent renders these as children, + # which is precisely the wire shape the driver demotes to String / BadNotSupported. + ("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5"), + ] + ) + self.send_conditions() + + def send_conditions(self) -> None: + """SHDR condition form: |||||.""" + stamp = timestamp() + self.send(f"{stamp}|Xtravel|normal||||") + self.send(f"{stamp}|Plogic|normal||||") + + def tick(self) -> None: + """One periodic batch. Called every TICK_SECONDS.""" + self._ticks += 1 + elapsed = time.monotonic() - self._started + + # Continuously-moving SAMPLEs. Rounded to 4 dp so the value is a clean double on + # the wire and still differs between any two consecutive ticks. + position = round(120.0 + 40.0 * math.sin(elapsed / 2.0), 4) + load = round(45.0 + 15.0 * math.sin(elapsed / 3.7), 4) + speed = round(1500.0 + 250.0 * math.sin(elapsed / 5.0), 4) + + pairs: list[tuple[str, str]] = [ + ("Xact", f"{position}"), + ("Xload", f"{load}"), + ("Cspeed", f"{speed}"), + ] + + # PART_COUNT: an integer EVENT that also moves. Slower than the samples so it reads + # like a real counter rather than a signal. + pairs.append(("Pcount", str(100 + int(elapsed // 5)))) + pairs.append(("Pline", str(10 + (self._ticks % 90)))) + + self.send_observations(pairs) + + # TIME_SERIES form: ||| ... + series = " ".join( + f"{round(21.5 + 0.5 * math.sin(elapsed + i / 4.0), 3)}" for i in range(8) + ) + self.send(f"{timestamp()}|Ctemps|8|100|{series}") + + # Controlled-vocabulary EVENTs, on their own slow cadences. + if self._ticks % 28 == 1: + self.send_observations( + [("Pexec", EXECUTION_STATES[(self._ticks // 28) % len(EXECUTION_STATES)])] + ) + if self._ticks % 44 == 1: + self.send_observations( + [("Cmode", CONTROLLER_MODES[(self._ticks // 44) % len(CONTROLLER_MODES)])] + ) + + # Re-assert conditions occasionally so a late-attaching driver still sees them. + if self._ticks % 60 == 0: + self.send_conditions() + + # NOTE: Pblock is never written, on purpose. See the module docstring. + + +def handle(conn: socket.socket, peer: str) -> None: + feed = Feed(conn, peer) + print(f"[adapter] agent connected from {peer}", flush=True) + + selector = selectors.DefaultSelector() + selector.register(conn, selectors.EVENT_READ) + + pending = b"" + next_tick = time.monotonic() + + try: + feed.send_initial_state() + + while True: + now = time.monotonic() + if now >= next_tick: + feed.tick() + # Absolute schedule, not `now + TICK`: drift-free, and a slow tick cannot + # compound into an ever-widening gap the Agent reads as a stall. + next_tick += TICK_SECONDS + if next_tick < now: + next_tick = now + TICK_SECONDS + + for _ in selector.select(timeout=max(0.0, next_tick - time.monotonic())): + chunk = conn.recv(4096) + if not chunk: + print(f"[adapter] agent {peer} closed the connection", flush=True) + + return + + pending += chunk + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + text = line.decode("utf-8", errors="replace").strip() + # The Agent's heartbeat. Answering is mandatory: an Agent that gets no + # PONG tears the connection down and every observation goes UNAVAILABLE. + if text.startswith("* PING"): + feed.send(f"* PONG {PONG_TIMEOUT_MS}") + except (BrokenPipeError, ConnectionResetError, OSError) as ex: + print(f"[adapter] connection to {peer} ended: {ex}", flush=True) + finally: + selector.close() + try: + conn.close() + except OSError: + pass + + +def main() -> int: + parser = argparse.ArgumentParser(description="SHDR adapter for the OtOpcUa MTConnect fixture.") + parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: all interfaces).") + parser.add_argument("--port", type=int, default=7878, help="SHDR listen port (default: 7878).") + args = parser.parse_args() + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((args.host, args.port)) + listener.listen(8) + print(f"[adapter] listening on {args.host}:{args.port}", flush=True) + + try: + while True: + conn, addr = listener.accept() + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + # One thread per Agent connection. The Agent reconnects after any restart, and + # a serialized accept loop would leave the new connection unserved while the + # dead one's socket was still being reaped. + threading.Thread( + target=handle, args=(conn, f"{addr[0]}:{addr[1]}"), daemon=True + ).start() + except KeyboardInterrupt: + return 0 + finally: + listener.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg new file mode 100644 index 00000000..bbc22692 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg @@ -0,0 +1,50 @@ +# MTConnect C++ Agent configuration for the OtOpcUa integration-test fixture. +# +# ASCII ONLY. The Agent's config parser rejects the whole file on a non-ASCII byte -- +# even inside a comment -- with a bare "Failed / Stopped at line: N" and exits. An em +# dash in a comment is enough to make the fixture never start. +# +# The image's CMD is `/usr/bin/mtcagent run /mtconnect/config/agent.cfg`, so this file is +# bind-mounted at exactly that path (see docker-compose.yml). Paths below are ABSOLUTE on +# purpose: a relative `Devices` is resolved against the Agent's working directory +# (/home/agent), not against this file, and would silently start an Agent with no device +# model. + +Devices = /mtconnect/config/Devices.xml +Port = 5000 +ServiceName = OtOpcUaMTConnectFixture + +# 2^14 = 16384 observations retained. Generous on purpose: the driver's /sample pump +# re-baselines through /current when its cursor falls out of the Agent's ring buffer, and +# a small buffer would make the fixture exercise that recovery path on every run instead +# of the steady-state streaming the suite is here to prove. +BufferSize = 14 +CheckpointFrequency = 1000 +MaxAssets = 128 + +# MTConnect is a read-only source for this driver; the Agent must not accept writes. +AllowPut = false + +SchemaVersion = 2.0 +MonitorConfigFiles = false +Pretty = true + +# The block name must equal the Device name attribute in Devices.xml (OtFixtureCnc) -- +# that is how the Agent binds an adapter connection to a device. "adapter" is the compose +# service name of the SHDR feeder; the Agent dials OUT to it. +Adapters { + OtFixtureCnc { + Host = adapter + Port = 7878 + ReconnectInterval = 2000 + } +} + +# Log to stdout rather than a file: /mtconnect/log is an image-declared VOLUME that is +# created root-owned while the Agent runs as uid 1000, so file logging fails to open. +# stdout also puts adapter-connect / device-model errors straight into `docker logs`, +# which is how you diagnose a fixture that comes up but reports everything UNAVAILABLE. +logger_config { + logging_level = info + output = cout +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml new file mode 100644 index 00000000..547861dc --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml @@ -0,0 +1,79 @@ +# MTConnect integration-test fixture — the official MTConnect C++ Agent, fed live data by +# a standard-library SHDR adapter. +# +# TWO services, and both are required: +# +# agent mtconnect/agent (the cppagent image). Serves /probe, /current and /sample on +# :5000. NOTE the repository name — the source project is `mtconnect/cppagent` +# but the published image is `mtconnect/agent`; `mtconnect/cppagent` does not +# exist on Docker Hub. +# adapter The data source. The agent image ships only the agent binary plus schemas and +# styles — there is NO bundled simulator — so without this every observation is +# UNAVAILABLE forever and the suite could prove nothing about reads or streaming. +# The Agent dials OUT to it (agent.cfg's Adapters block); it is not published to +# the host. +# +# Why pinned: the `latest` tag moves and a fixture that silently changes device-model +# behaviour turns a driver regression into an unexplained red. Bump deliberately. +# +# Usage (from the docker host, stack dir /opt/otopcua-mtconnect): +# docker compose up -d --wait +# docker compose down +# +# Or from a dev box, via the helper (see CLAUDE.md "Docker Workflow"): +# lmxopcua-fix sync mtconnect +# lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument +services: + agent: + image: mtconnect/agent:2.7.0.12 + container_name: otopcua-mtconnect-agent + restart: "no" + labels: + project: lmxopcua + depends_on: + adapter: + condition: service_started + ports: + # Host port is overridable because macOS squats :5000 — AirPlay Receiver + # (ControlCenter) binds *:5000 and WINS the race, so `docker port` reports a healthy + # publish while every request answers "403 Forbidden / Server: AirTunes". On the + # Linux docker host the default is correct and needs no override. To run the fixture + # on a Mac: + # MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait + # MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test ... + - "${MTCONNECT_AGENT_HOST_PORT:-5000}:5000" + volumes: + # Individual FILE binds, not a directory bind: /mtconnect/config is a VOLUME declared + # by the image, and mounting this whole folder over it would also drop docker-compose.yml + # and adapter.py into the Agent's config directory. + - ./agent.cfg:/mtconnect/config/agent.cfg:ro + - ./Devices.xml:/mtconnect/config/Devices.xml:ro + healthcheck: + # The image is alpine-based, so busybox wget is present (there is no curl). A 200 from + # /probe is the real readiness signal — the port accepts connections before the device + # model is parsed. + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5000/probe || exit 1"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + + adapter: + image: python:3.13-alpine + container_name: otopcua-mtconnect-adapter + restart: "no" + labels: + project: lmxopcua + volumes: + - ./adapter.py:/fixtures/adapter.py:ro + # Stock image + a bind-mounted script: this fixture needs no `build:` step at all, unlike + # the Modbus / S7 / AB fixtures whose simulators are built from a Dockerfile. + command: ["python", "-u", "/fixtures/adapter.py", "--host", "0.0.0.0", "--port", "7878"] + expose: + - "7878" + healthcheck: + test: ["CMD-SHELL", "python -c \"import socket; socket.create_connection(('127.0.0.1', 7878), timeout=2).close()\" || exit 1"] + interval: 5s + timeout: 3s + retries: 6 + start_period: 3s diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs new file mode 100644 index 00000000..dad972f8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs @@ -0,0 +1,190 @@ +using System.Net.Http; +using System.Xml.Linq; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests; + +/// +/// Reachability probe for a live MTConnect Agent (the mtconnect/agent container in +/// Docker/docker-compose.yml, or a real Agent on a machine tool). Reads +/// MTCONNECT_AGENT_ENDPOINT (default http://10.100.0.35:5000 — the shared Docker +/// host) and issues ONE GET {endpoint}/probe at fixture construction. Each test checks +/// and calls Assert.Skip when the Agent was unreachable, so a +/// dev box with no fixture running still passes dotnet test cleanly — the same pattern +/// as ModbusSimulatorFixture / S7SimulatorFixture. +/// +/// +/// +/// The probe is an HTTP round-trip, not a TCP connect — unlike the Modbus and S7 +/// fixtures, whose protocols have nothing cheaper. An Agent's port accepts connections +/// before its device model is parsed, and a mis-authored Devices.xml produces an +/// Agent that answers TCP and then fails every request. A TCP-only probe would let the +/// whole suite run and fail confusingly rather than skip with an actionable message. +/// +/// +/// The probe response is kept () and is the source of +/// truth every assertion in this suite is written against. Nothing here may hard-code a +/// DataItem id: the seeded Devices.xml is expected to change, and a real Agent on a +/// real machine is a completely different model. Tests therefore say "for every DataItem +/// the Agent declares with category=EVENT and type PART_COUNT, the discovered variable must +/// be Int64" — a statement that survives any device model, including one with zero such +/// items (which self-skips rather than passing vacuously). +/// +/// +/// A collection fixture, so the probe runs once per session rather than once per +/// test: against a firewalled endpoint each attempt costs the full timeout. +/// +/// +public sealed class MTConnectAgentFixture : IAsyncDisposable +{ + /// The shared Docker host (see CLAUDE.md "Docker Workflow"); port 5000 is the Agent's own default. + private const string DefaultEndpoint = "http://10.100.0.35:5000"; + + private const string EndpointEnvVar = "MTCONNECT_AGENT_ENDPOINT"; + + /// + /// Bound on the one-shot reachability probe. Deliberately generous relative to a TCP probe: + /// a cold Agent parses its device model on the first request, and a large real-world model + /// can take a second or two to serialize. + /// + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10); + + private static readonly string HowToStart = + $"Start the fixture (docker compose -f Docker/docker-compose.yml up -d --wait) or point " + + $"{EndpointEnvVar} at a live Agent, then re-run."; + + /// Gets the Agent's base URI, exactly as an authored agentUri would carry it. + public string AgentUri { get; } + + /// Gets the skip reason when the Agent is unreachable or unusable; otherwise null. + public string? SkipReason { get; } + + /// + /// Gets the Agent's own /probe response, parsed as XML — null exactly when + /// is set. Tests cross-reference discovery output against this so + /// their assertions describe the Agent's declared model rather than a hard-coded fixture. + /// + public XDocument? ProbeDocument { get; } + + /// Initializes a new instance of the class. + public MTConnectAgentFixture() + { + AgentUri = (Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint).Trim().TrimEnd('/'); + + if (!Uri.TryCreate(AgentUri, UriKind.Absolute, out var parsed) || + (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + { + SkipReason = $"{EndpointEnvVar} ('{AgentUri}') is not an absolute http(s) URI. {HowToStart}"; + + return; + } + + try + { + using var http = new HttpClient { Timeout = ProbeTimeout }; + using var response = http.GetAsync($"{AgentUri}/probe").GetAwaiter().GetResult(); + + if (!response.IsSuccessStatusCode) + { + SkipReason = + $"MTConnect Agent at {AgentUri} answered /probe with HTTP {(int)response.StatusCode} " + + $"({response.ReasonPhrase}). {HowToStart}"; + + return; + } + + var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + var document = XDocument.Parse(body); + + // An MTConnectError document (or anything else) served under a 2xx must skip, not run. + // The suite's whole value is asserting against a real device model; running it against + // a document that is not one produces confusing red rather than an honest skip. + if (document.Root is null || document.Root.Name.LocalName != "MTConnectDevices") + { + SkipReason = + $"MTConnect Agent at {AgentUri} answered /probe with a <{document.Root?.Name.LocalName ?? "?"}> " + + $"document, not . {HowToStart}"; + + return; + } + + ProbeDocument = document; + } + catch (Exception ex) + { + SkipReason = $"MTConnect Agent at {AgentUri} is unreachable: {ex.GetType().Name}: {ex.Message}. {HowToStart}"; + } + } + + /// + /// Gets every DataItem the Agent declares, flattened out of + /// with the attributes the type inference consumes. + /// + /// + /// Read straight off the XML with LINQ-to-XML rather than through the driver's own + /// MTConnectProbeParser: that parser is what several of these tests are checking, and + /// an assertion that ran the code under test to build its own expectation could only ever + /// prove the code agrees with itself. Matching is on local names because an MTConnect + /// document is namespaced and the namespace URI carries the schema version. + /// + public IReadOnlyList DeclaredDataItems => + ProbeDocument is null + ? [] + : [.. ProbeDocument.Descendants() + .Where(e => e.Name.LocalName == "DataItem") + .Select(e => new DeclaredDataItem( + Id: (string?)e.Attribute("id") ?? string.Empty, + Name: (string?)e.Attribute("name"), + Category: (string?)e.Attribute("category") ?? string.Empty, + Type: (string?)e.Attribute("type") ?? string.Empty, + Units: (string?)e.Attribute("units"), + Representation: (string?)e.Attribute("representation"), + InComposition: e.Ancestors().Any(a => a.Name.LocalName == "Compositions"), + InAgentSelfModel: e.Ancestors().Any(a => a.Name.LocalName == "Agent"))) + .Where(d => d.Id.Length > 0)]; + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +/// +/// One DataItem as the live Agent declares it in its /probe response — the +/// expectation side of every discovery assertion in this suite. +/// +/// The id attribute: the observation correlation key, and what discovery must stamp as FullName. +/// The name attribute when present: cosmetic, and what discovery must use as the browse name. +/// The category attribute — SAMPLE, EVENT, or CONDITION. +/// The type attribute in the probe document's UPPER_SNAKE spelling (PART_COUNT, not PartCount). +/// The units attribute when present. +/// The representation attribute when present (TIME_SERIES, DATA_SET, …). +/// +/// true when this DataItem is declared under a <Compositions> element rather +/// than under a component's own <DataItems>. The driver's discovery walk recurses +/// <Components> only, so a composition-owned item is legitimately absent from the +/// browse tree; assertions about full coverage of the device model must exclude these or they +/// become a false red on any Agent whose model uses compositions. +/// +/// +/// true when this DataItem belongs to the Agent's own <Agent> self-model +/// (connection status, observation update rate, adapter URI, …) rather than to a +/// <Device>. Every MTConnect 2.x Agent publishes one, and its update-rate SAMPLEs +/// move continuously whether or not any adapter is attached — so a "the stream delivered a +/// changed value" assertion that did not exclude these would pass against an Agent with no data +/// source at all, which is exactly the vacuous green this suite must not have. +/// +public sealed record DeclaredDataItem( + string Id, + string? Name, + string Category, + string Type, + string? Units, + string? Representation, + bool InComposition, + bool InAgentSelfModel); + +/// Collection definition so the reachability probe runs once per test session. +[Xunit.CollectionDefinition(Name)] +public sealed class MTConnectAgentCollection : Xunit.ICollectionFixture +{ + /// The collection name every test class in this suite joins. + public const string Name = "MTConnectAgent"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs new file mode 100644 index 00000000..2e08a622 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs @@ -0,0 +1,697 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Net.Http; +using System.Net.Sockets; +using System.Text.Json; +using System.Xml.Linq; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests; + +/// +/// The MTConnect driver against a real MTConnect Agent (the mtconnect/agent +/// fixture in Docker/, or a live Agent on a machine tool via +/// MTCONNECT_AGENT_ENDPOINT). Everything else in the driver's test surface runs on canned +/// XML; this suite is the only thing that touches a socket. +/// +/// +/// +/// What only a live Agent can prove, and therefore what every test here is aimed at: +/// +/// +/// +/// +/// UPPER_SNAKE type spellings. A /probe document writes +/// DataItem@type as PART_COUNT; a streams document names the same +/// concept PartCount. An earlier revision of the inference matched only the +/// PascalCase spelling, typed every real Agent's part counter as +/// , and passed every unit test — because the +/// hand-authored fixtures used the streams spelling in the probe document too. +/// +/// +/// +/// +/// name differs from id. On a real Agent most DataItems carry +/// both, and they differ. The canned fixtures use ids that double as names, which is +/// the one arrangement under which confusing the two is invisible. +/// +/// +/// +/// +/// A real multipart/x-mixed-replace stream. The canned subscribe tests +/// drive a fake pump that hands the driver pre-parsed chunks; real boundaries, real +/// chunked transfer-encoding, and real heartbeats are a different thing entirely. +/// +/// +/// +/// +/// Assertions are structural, never by literal id. Every expectation is computed from +/// the Agent's OWN /probe response (), +/// so the suite survives an edit to the seeded Devices.xml and can be pointed at a +/// real machine tool. A test whose subject the Agent does not declare (no CONDITION, no +/// PART_COUNT) Assert.Skips rather than passing vacuously. +/// +/// +/// Two things this suite deliberately does NOT cover, with reasons. +/// (a) MTConnectError under HTTP 200. The driver handles it and canned tests +/// pin it, but a modern Agent cannot be made to produce it: mtconnect/agent 2.7 +/// answers an unknown device with 404 and an out-of-range sequence with 400, +/// each carrying a well-formed MTConnectError body (verified live). The under-200 +/// shape belongs to older/third-party Agents and stays canned-only. +/// (b) Writes. MTConnect is a read-only source protocol and the driver implements no +/// IWritable; the fixture Agent runs AllowPut = false to match. +/// +/// +[Collection(MTConnectAgentCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Driver", "MTConnect")] +public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) +{ + /// + /// Hard per-test ceiling, in milliseconds. Every test also bounds its own awaits, but a + /// live-integration suite must never be able to wedge a build against a half-up fixture — + /// an Agent that completes its TCP handshake and then never answers would otherwise hang + /// inside the driver's own retry loop. + /// + private const int TestTimeoutMs = 90_000; + + /// Canonical Opc.Ua.StatusCodes numerics, restated so the assertion names the wire value a client sees. + private const uint Good = 0x00000000u; + + private const uint BadNoCommunication = 0x80310000u; + + /// How long a subscription test waits for the live /sample stream to move a value. + private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // ---- reachability ---- + + /// + /// The AdminUI Test Connect path against a live Agent: + /// must go green and report a latency. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Probe_reports_the_live_agent_reachable() + { + SkipIfDown(); + + var result = await new MTConnectDriverProbe() + .ProbeAsync(ProbeConfig(agent.AgentUri), TimeSpan.FromSeconds(15), Ct); + + result.Ok.ShouldBeTrue($"Test Connect must go green against the live Agent. Message: {result.Message}"); + result.Message.ShouldNotBeNull(); + result.Message.ShouldContain("/probe"); + result.Latency.ShouldNotBeNull(); + } + + /// + /// Falsifiability control for : the same + /// probe against a port nothing is listening on must go RED. Without this, a probe that + /// returned Ok = true unconditionally would satisfy the green test forever. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Probe_reports_a_dead_endpoint_as_unreachable() + { + SkipIfDown(); + + var result = await new MTConnectDriverProbe() + .ProbeAsync(ProbeConfig($"http://127.0.0.1:{ClosedLoopbackPort()}"), TimeSpan.FromSeconds(5), Ct); + + result.Ok.ShouldBeFalse("a probe against a closed port must fail, or the green probe proves nothing"); + result.Latency.ShouldBeNull(); + } + + // ---- discovery: the /probe device model ---- + + /// + /// DiscoverAsync streams a non-empty, genuinely nested tree, and every leaf in it is + /// a DataItem the Agent actually declares — no invented references, and nothing dropped + /// except the composition-owned items the walk is documented not to visit. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Discovery_streams_the_agents_declared_device_model() + { + SkipIfDown(); + + var capture = await DiscoverAsync(); + + capture.Variables.ShouldNotBeEmpty("a live Agent declares data items; an empty browse tree is the #485 shape"); + capture.Folders.ShouldNotBeEmpty(); + capture.Folders.ShouldContain( + f => f.ParentPath.Length > 0, + "the walk must recurse , not just emit one folder per device"); + + var declared = agent.DeclaredDataItems; + var declaredIds = declared.Select(d => d.Id).ToHashSet(StringComparer.Ordinal); + var discoveredIds = capture.Variables.Select(v => v.Attr.FullName).ToHashSet(StringComparer.Ordinal); + + discoveredIds.Except(declaredIds).ShouldBeEmpty("discovery must not invent references the Agent never declared"); + + var expected = declared.Where(d => !d.InComposition).Select(d => d.Id).ToHashSet(StringComparer.Ordinal); + expected.Except(discoveredIds).ShouldBeEmpty("every component-owned DataItem the Agent declares must be browsable"); + } + + /// + /// The regression this whole fixture exists for. An EVENT whose probe-document + /// type is the UPPER_SNAKE PART_COUNT / LINE_NUMBER must infer + /// . Matching only the streams document's PascalCase + /// PartCount types it — green in every unit test, + /// wrong on every real Agent. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Discovery_types_an_upper_snake_numeric_event_as_Int64() + { + SkipIfDown(); + + var numericEvents = agent.DeclaredDataItems + .Where(d => Is(d.Category, "EVENT")) + .Where(d => Is(d.Type, "PART_COUNT") || Is(d.Type, "LINE_NUMBER") || Is(d.Type, "LINE")) + .ToList(); + + if (numericEvents.Count == 0) + { + Assert.Skip( + $"The Agent at {agent.AgentUri} declares no PART_COUNT / LINE_NUMBER EVENT, so the " + + "UPPER_SNAKE numeric-event inference has nothing to assert against here."); + } + + var capture = await DiscoverAsync(); + + foreach (var item in numericEvents) + { + var variable = Leaf(capture, item.Id); + variable.Attr.DriverDataType.ShouldBe( + DriverDataType.Int64, + $"DataItem '{item.Id}' (category={item.Category}, type={item.Type}) is an integer EVENT; " + + "typing it String is the UPPER_SNAKE-vs-PascalCase defect this suite exists to catch"); + variable.Attr.IsArray.ShouldBeFalse(); + } + } + + /// + /// A SAMPLE is a measured quantity by definition of the category, so it must infer + /// — scalar for an ordinary sample, an array for a + /// TIME_SERIES. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Discovery_types_samples_as_Float64_and_time_series_as_a_float_array() + { + SkipIfDown(); + + var samples = agent.DeclaredDataItems + .Where(d => Is(d.Category, "SAMPLE") && !d.InComposition) + .Where(d => !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE")) + .ToList(); + + if (samples.Count == 0) + { + Assert.Skip($"The Agent at {agent.AgentUri} declares no scalar/time-series SAMPLE data items."); + } + + var capture = await DiscoverAsync(); + + foreach (var item in samples) + { + var variable = Leaf(capture, item.Id); + variable.Attr.DriverDataType.ShouldBe( + DriverDataType.Float64, + $"DataItem '{item.Id}' is category=SAMPLE (type={item.Type}, units={item.Units ?? ""})"); + + if (Is(item.Representation, "TIME_SERIES")) + { + variable.Attr.IsArray.ShouldBeTrue($"'{item.Id}' declares representation=TIME_SERIES"); + + // Not a bug and not an oversight: `sampleCount` is an attribute of a TIME_SERIES + // *observation*, not of a DataItem declaration. A real Agent REJECTS the whole + // DataItem if the declaration carries one ("The following keys were present and not + // expected: sampleCount" -> "Invalid element 'DataItem'"), so the inference can only + // ever see null here and a live TIME_SERIES tag is always variable-length. Asserted + // rather than ignored so a future change that starts fabricating a dimension is loud. + variable.Attr.ArrayDim.ShouldBeNull( + $"'{item.Id}': a real Agent never carries sampleCount on a DataItem declaration"); + } + else + { + variable.Attr.IsArray.ShouldBeFalse($"'{item.Id}' declares no TIME_SERIES representation"); + } + } + } + + /// + /// A CONDITION is a state word, never a number, whatever its type says — and it + /// is the one category discovery flags as an alarm. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Discovery_types_a_condition_as_a_string_alarm() + { + SkipIfDown(); + + var conditions = agent.DeclaredDataItems + .Where(d => Is(d.Category, "CONDITION") && !d.InComposition) + .ToList(); + + if (conditions.Count == 0) + { + Assert.Skip($"The Agent at {agent.AgentUri} declares no CONDITION data items."); + } + + var capture = await DiscoverAsync(); + + foreach (var item in conditions) + { + var variable = Leaf(capture, item.Id); + variable.Attr.DriverDataType.ShouldBe(DriverDataType.String, $"CONDITION '{item.Id}' reports a state word"); + variable.Attr.IsAlarm.ShouldBeTrue($"CONDITION '{item.Id}' is an alarm-bearing data item"); + variable.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + } + } + + /// + /// The FullName-vs-browse-name split, on the shape that makes it visible. The + /// driver-side reference must be the DataItem's id (the observation correlation key) + /// while the browse name is its name. Committing the name instead produces a picker + /// that looks perfect and authors tags that can never report a value — and it is invisible on + /// the canned fixtures, whose ids double as names. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs() + { + SkipIfDown(); + + var renamed = agent.DeclaredDataItems + .Where(d => !d.InComposition) + .Where(d => !string.IsNullOrEmpty(d.Name) && !string.Equals(d.Name, d.Id, StringComparison.Ordinal)) + .ToList(); + + if (renamed.Count == 0) + { + Assert.Skip( + $"Every DataItem the Agent at {agent.AgentUri} declares has name == id (or no name), so " + + "the id-vs-name split cannot be observed against this device model."); + } + + var capture = await DiscoverAsync(); + + foreach (var item in renamed) + { + var variable = Leaf(capture, item.Id); + variable.Attr.FullName.ShouldBe(item.Id, "the driver-side reference must be the DataItem id"); + variable.BrowseName.ShouldBe(item.Name, "the browse name must prefer the DataItem name"); + } + } + + /// + /// A device scope that matches nothing must fail loudly. It must never degrade to an + /// empty-but-successful browse — indistinguishable from a working connection to a machine + /// that declares nothing (#485). + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Discovery_scoped_to_an_unknown_device_fails_rather_than_browsing_empty() + { + SkipIfDown(); + + var driver = new MTConnectDriver( + LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}"), "mtconnect-live-unknown-device"); + var capture = new DiscoveryCapture(); + + try + { + // Either leg may be the one that fails — the Agent answers /probe and /current for an + // unknown device with 404 + an MTConnectError body — so the assertion is that ONE of + // them did, and that nothing was streamed. + var failed = false; + try + { + await driver.InitializeAsync(ConfigJson(LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}")), Ct); + await driver.DiscoverAsync(capture, Ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + failed = true; + } + + failed.ShouldBeTrue("an unknown device scope must surface as a failure, never as an empty browse"); + capture.Variables.ShouldBeEmpty(); + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + // ---- reads: the /current snapshot ---- + + /// + /// ReadAsync returns live, coerced values off the Agent's /current snapshot for + /// the numeric samples it is currently reporting. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Read_returns_live_values_for_the_agents_numeric_samples() + { + SkipIfDown(); + + var ids = await NumericSampleIdsAsync(); + if (ids.Count == 0) + { + Assert.Skip( + $"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value " + + "(every sample is UNAVAILABLE). Is the fixture's adapter service running?"); + } + + await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64))); + + var results = await live.Driver.ReadAsync([.. ids], Ct); + + results.Count.ShouldBe(ids.Count); + + var good = results.Where(r => r.StatusCode == Good).ToList(); + good.ShouldNotBeEmpty("at least one numeric SAMPLE the Agent reports a value for must read Good"); + good.ShouldAllBe(r => r.Value is double); + good.ShouldAllBe(r => r.SourceTimestampUtc != null && r.SourceTimestampUtc.Value.Kind == DateTimeKind.Utc); + } + + /// + /// The Agent's literal UNAVAILABLE must map to BadNoCommunication — not to a + /// Good empty string, and not to the "no value yet" code, which means something different to + /// an operator. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Read_maps_an_unavailable_observation_to_BadNoCommunication() + { + SkipIfDown(); + + var unavailable = (await CurrentAsync()) + .Descendants() + .Where(e => (string?)e.Attribute("dataItemId") is { Length: > 0 }) + .Where(e => !e.HasElements && string.Equals(e.Value.Trim(), "UNAVAILABLE", StringComparison.Ordinal)) + .Select(e => (string)e.Attribute("dataItemId")!) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (unavailable.Count == 0) + { + Assert.Skip( + $"The Agent at {agent.AgentUri} is currently reporting no UNAVAILABLE observation, so the " + + "UNAVAILABLE -> BadNoCommunication mapping has nothing live to assert against."); + } + + await using var live = await LiveDriverAsync(unavailable.Select(id => Tag(id, DriverDataType.String))); + + var results = await live.Driver.ReadAsync([.. unavailable], Ct); + + results.Count.ShouldBe(unavailable.Count); + results.ShouldAllBe(r => r.StatusCode == BadNoCommunication); + results.ShouldAllBe(r => r.Value == null); + } + + // ---- subscriptions: the /sample long poll ---- + + /// + /// The live streaming proof. A real multipart/x-mixed-replace body, with real + /// boundaries and real heartbeats, must drive OnDataChange — and must drive it with a + /// value that has actually MOVED since the primed /current baseline. Asserting merely + /// that a callback arrived would be satisfied by the priming callback alone, which is + /// answered out of the snapshot and proves nothing about the stream. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Subscribe_delivers_a_changed_value_from_the_live_sample_stream() + { + SkipIfDown(); + + var ids = await NumericSampleIdsAsync(); + if (ids.Count == 0) + { + Assert.Skip( + $"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value, so no " + + "observation can be expected to move. Is the fixture's adapter service running?"); + } + + await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64))); + + var seen = new ConcurrentQueue(); + var moved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var baseline = new ConcurrentDictionary(StringComparer.Ordinal); + + live.Driver.OnDataChange += (_, e) => + { + seen.Enqueue(e); + if (e.Snapshot.StatusCode != Good || e.Snapshot.Value is not double value) + { + return; + } + + // First Good value per reference is the baseline (the priming callback); a later one + // that differs is the stream doing its job. + if (!baseline.TryAdd(e.FullReference, value) && + baseline.TryGetValue(e.FullReference, out var first) && + Math.Abs(first - value) > 1e-9) + { + moved.TrySetResult(); + } + }; + + var handle = await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct); + try + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct); + deadline.CancelAfter(StreamWait); + + var finished = await Task.WhenAny(moved.Task, Task.Delay(Timeout.Infinite, deadline.Token)); + + (finished == moved.Task).ShouldBeTrue( + $"the live /sample stream must deliver a CHANGED value within {StreamWait.TotalSeconds:F0}s. " + + $"Callbacks seen: {seen.Count}; references with a Good baseline: {baseline.Count}."); + + seen.Count.ShouldBeGreaterThan(ids.Count, "more callbacks than the one priming callback per reference"); + } + finally + { + await live.Driver.UnsubscribeAsync(handle, CancellationToken.None); + } + } + + // ---- the v3 production shape: RawPath identity ---- + + /// + /// The shape a deployed driver actually serves: the artifact injects RawTags + /// (RawPath + a TagConfig blob naming the DataItem id), and the driver must read and + /// publish by RawPath. A driver that answered under its own DataItem id would be + /// dropped silently by DriverHostActor's RawPath-keyed routing table, with every unit + /// test still green. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags() + { + SkipIfDown(); + + var ids = (await NumericSampleIdsAsync()).Take(4).ToList(); + if (ids.Count == 0) + { + Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value."); + } + + var rawPaths = ids.ToDictionary(id => id, id => $"Plant/MTConnect/live/{id}", StringComparer.Ordinal); + + // The real merge DeploymentArtifact.TryReadSpec runs, not a hand-written JSON literal: this + // proves the driver binds the shape the deploy artifact actually produces. + var merged = DriverDeviceConfigMerger.Merge( + ConfigJson(LiveOptions()), + [new DriverDeviceConfigMerger.DeviceRow("live", "{}")], + [.. ids.Select(id => new RawTagEntry( + rawPaths[id], + $$"""{"fullName":"{{id}}","driverDataType":"Float64"}""", + WriteIdempotent: false, + DeviceName: "live"))]); + + var driver = new MTConnectDriver(LiveOptions(), "mtconnect-live-rawtags"); + try + { + await driver.InitializeAsync(merged, Ct); + + var byRawPath = await driver.ReadAsync([.. rawPaths.Values], Ct); + byRawPath.Count.ShouldBe(ids.Count); + byRawPath.ShouldContain(r => r.StatusCode == Good && r.Value is double, "reads must resolve by RawPath"); + + var seen = new ConcurrentQueue(); + driver.OnDataChange += (_, e) => seen.Enqueue(e); + + var handle = await driver.SubscribeAsync([.. rawPaths.Values], TimeSpan.FromMilliseconds(200), Ct); + try + { + seen.ShouldNotBeEmpty("subscribe primes every reference from the /current snapshot"); + seen.Select(e => e.FullReference) + .ShouldAllBe(reference => rawPaths.Values.Contains(reference)); + } + finally + { + await driver.UnsubscribeAsync(handle, CancellationToken.None); + } + } + finally + { + await driver.ShutdownAsync(CancellationToken.None); + } + } + + // ---- helpers ---- + + private void SkipIfDown() + { + if (agent.SkipReason is not null) + { + Assert.Skip(agent.SkipReason); + } + } + + private static bool Is(string? value, string expected) => + string.Equals(value?.Trim(), expected, StringComparison.OrdinalIgnoreCase); + + /// The minimal Test Connect config: the probe reads nothing but agentUri. + private static string ProbeConfig(string agentUri) => + JsonSerializer.Serialize(new { agentUri }); + + /// + /// A loopback port with nothing listening on it: bound to :0 to let the OS pick a free one, + /// then released. Racy in principle, effectively certain in practice, and far more reliable + /// than guessing an unused constant. + /// + private static int ClosedLoopbackPort() + { + var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + return port; + } + + private MTConnectDriverOptions LiveOptions(string? deviceName = null) => + new() + { + AgentUri = agent.AgentUri, + DeviceName = deviceName, + RequestTimeoutMs = 15_000, + + // Faster than the 1000 ms default so a subscription sees several distinct chunks inside + // StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on. + SampleIntervalMs = 200, + SampleCount = 500, + HeartbeatMs = 5_000, + + // The background connectivity probe is off: it dials the Agent on its own cadence and + // would interleave request logs with the ones a failing test needs to be read from. + Probe = new MTConnectProbeOptions { Enabled = false }, + }; + + private static string ConfigJson(MTConnectDriverOptions options, IEnumerable? tags = null) => + JsonSerializer.Serialize(new + { + agentUri = options.AgentUri, + deviceName = options.DeviceName, + requestTimeoutMs = options.RequestTimeoutMs, + sampleIntervalMs = options.SampleIntervalMs, + sampleCount = options.SampleCount, + heartbeatMs = options.HeartbeatMs, + probe = new { enabled = false }, + tags = (tags ?? []).Select(t => new + { + fullName = t.FullName, + driverDataType = t.DriverDataType.ToString(), + }), + }); + + private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type); + + /// Initializes a driver against the live Agent with the supplied authored tags. + private async Task LiveDriverAsync(IEnumerable tags) + { + var options = LiveOptions(); + var driver = new MTConnectDriver(options, "mtconnect-live"); + await driver.InitializeAsync(ConfigJson(options, tags), Ct); + + return new LiveDriver(driver); + } + + /// Initializes a driver and captures one full DiscoverAsync pass. + private async Task DiscoverAsync() + { + await using var live = await LiveDriverAsync([]); + + var capture = new DiscoveryCapture(); + await live.Driver.DiscoverAsync(capture, Ct); + + return capture; + } + + /// The single captured variable for a DataItem id, asserting it was streamed exactly once. + private static CapturedVariable Leaf(DiscoveryCapture capture, string dataItemId) + { + var matches = capture.Variables.Where(v => v.Attr.FullName == dataItemId).ToList(); + matches.Count.ShouldBe(1, $"DataItem '{dataItemId}' must be streamed exactly once by discovery"); + + return matches[0]; + } + + /// The Agent's /current snapshot, fetched straight over HTTP. + /// + /// Read outside the driver on purpose: several assertions need to know what the Agent is + /// reporting right now in order to build their expectation, and computing that with + /// the parser under test would only prove the code agrees with itself. + /// + private async Task CurrentAsync() + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var body = await http.GetStringAsync($"{agent.AgentUri}/current", Ct); + + return XDocument.Parse(body); + } + + /// + /// The DataItem ids the Agent is currently reporting a parseable number for: the live subset + /// a read or subscription can make a non-vacuous statement about. + /// + /// + /// The Agent's own self-model is excluded, and that exclusion is load-bearing. Every + /// MTConnect 2.x Agent publishes an <Agent> device whose + /// OBSERVATION_UPDATE_RATE / ASSET_UPDATE_RATE SAMPLEs tick continuously + /// regardless of whether any adapter is attached. Including them made + /// pass with the + /// fixture's data source deliberately stopped (verified) — it was proving the Agent's own + /// heartbeat, not the device stream. Restricted to <Device>-owned samples, an + /// Agent with no live source yields an empty set and the test skips with an actionable + /// message instead. + /// + private async Task> NumericSampleIdsAsync() + { + var declaredSamples = agent.DeclaredDataItems + .Where(d => Is(d.Category, "SAMPLE") && !d.InAgentSelfModel && !d.InComposition) + .Where(d => !Is(d.Representation, "TIME_SERIES") && !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE")) + .Select(d => d.Id) + .ToHashSet(StringComparer.Ordinal); + + return [.. (await CurrentAsync()) + .Descendants() + .Where(e => !e.HasElements) + .Select(e => ((string?)e.Attribute("dataItemId"), e.Value.Trim())) + .Where(pair => pair.Item1 is { Length: > 0 } && declaredSamples.Contains(pair.Item1)) + .Where(pair => double.TryParse(pair.Item2, NumberStyles.Float, CultureInfo.InvariantCulture, out _)) + .Select(pair => pair.Item1!) + .Distinct(StringComparer.Ordinal)]; + } + + /// + /// Scope guard: is not disposable (its teardown is + /// ShutdownAsync), and a test that failed mid-way would otherwise leave a live + /// /sample long poll running against the fixture for the rest of the session. + /// + private sealed class LiveDriver(MTConnectDriver driver) : IAsyncDisposable + { + public MTConnectDriver Driver { get; } = driver; + + public async ValueTask DisposeAsync() => await Driver.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj new file mode 100644 index 00000000..0499fd05 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj @@ -0,0 +1,41 @@ + + + + + $(NoWarn);OTOPCUA0001 + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + +