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 <Agent> 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.
This commit is contained in:
+110
@@ -0,0 +1,110 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IAddressSpaceBuilder"/> that records the tree <c>DiscoverAsync</c> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A near-twin of the MTConnect unit suite's <c>CapturingBuilder</c>, restated here rather than
|
||||
/// shared: that type is <c>internal</c> to a different test assembly, and the two production
|
||||
/// capturing builders (<c>Commons.Browsing</c> / <c>Runtime.Drivers</c>) would drag the server
|
||||
/// stack into a driver integration project that deliberately references only the driver.
|
||||
/// </remarks>
|
||||
internal sealed class DiscoveryCapture : IAddressSpaceBuilder
|
||||
{
|
||||
private readonly State _state;
|
||||
private readonly string _path;
|
||||
|
||||
/// <summary>Creates the root scope a driver's <c>DiscoverAsync</c> is handed.</summary>
|
||||
public DiscoveryCapture()
|
||||
{
|
||||
_state = new State();
|
||||
_path = string.Empty;
|
||||
}
|
||||
|
||||
private DiscoveryCapture(State state, string path)
|
||||
{
|
||||
_state = state;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
/// <summary>Every folder streamed, with the slash-joined path it landed at.</summary>
|
||||
public IReadOnlyList<CapturedFolder> Folders => _state.Folders;
|
||||
|
||||
/// <summary>Every variable streamed, with the folder path it landed under.</summary>
|
||||
public IReadOnlyList<CapturedVariable> Variables => _state.Variables;
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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<CapturedFolder> Folders { get; } = [];
|
||||
|
||||
public List<CapturedVariable> 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One folder captured from a discovery stream.</summary>
|
||||
/// <param name="Path">Slash-joined path of the folder itself.</param>
|
||||
/// <param name="ParentPath">Slash-joined path of the scope it was added to (empty at the root).</param>
|
||||
/// <param name="BrowseName">The browse name the driver supplied.</param>
|
||||
/// <param name="DisplayName">The display name the driver supplied.</param>
|
||||
internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
|
||||
|
||||
/// <summary>One variable captured from a discovery stream.</summary>
|
||||
/// <param name="ParentPath">Slash-joined path of the folder it landed under.</param>
|
||||
/// <param name="BrowseName">The browse name the driver supplied.</param>
|
||||
/// <param name="DisplayName">The display name the driver supplied.</param>
|
||||
/// <param name="Attr">The driver-side attribute metadata the driver stamped on it.</param>
|
||||
internal sealed record CapturedVariable(
|
||||
string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
|
||||
{
|
||||
/// <summary>Alarm conditions the driver marked this variable with, if any.</summary>
|
||||
public List<AlarmConditionInfo> AlarmConditions { get; } = [];
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
MTConnect integration-test device model — the canned Devices.xml the fixture Agent
|
||||
(mtconnect/agent) serves from /probe.
|
||||
|
||||
This model is authored to exercise every branch of MTConnectDataTypeInference.Infer,
|
||||
and to cover the two things the driver's canned unit-test fixtures structurally CANNOT:
|
||||
|
||||
1. UPPER_SNAKE type spellings. A probe document writes DataItem@type in UPPER_SNAKE
|
||||
(PART_COUNT, ROTARY_VELOCITY); a streams document names the same concept in
|
||||
PascalCase (PartCount, RotaryVelocity). The inference is separator-insensitive to
|
||||
bridge the two, and a real Agent is the only thing that proves it — an earlier
|
||||
revision typed PART_COUNT as String and passed every unit test.
|
||||
|
||||
2. name != id on every named DataItem. The hand-authored unit fixtures use ids like
|
||||
"dev1_pos" that double as browse names; on a real machine tool the two always
|
||||
differ, and DriverAttributeInfo.FullName must be the *id* (the observation
|
||||
correlation key) while the browse name is the *name*. Every DataItem below that
|
||||
carries a name deliberately gives it a value unequal to its id.
|
||||
|
||||
Inference coverage map (category / type / representation -> expected DriverDataType):
|
||||
|
||||
SAMPLE, units -> Float64 fixture_x_pos, fixture_x_load, fixture_c_speed
|
||||
SAMPLE, TIME_SERIES, sampleCount -> Float64[] array fixture_c_temp_series
|
||||
EVENT, PART_COUNT -> Int64 fixture_partcount (the regression case)
|
||||
EVENT, LINE_NUMBER -> Int64 fixture_linenumber
|
||||
EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode
|
||||
EVENT, free text -> String fixture_program, fixture_block (never fed)
|
||||
EVENT, DATA_SET representation -> String fixture_varset
|
||||
CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic
|
||||
|
||||
fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it
|
||||
UNAVAILABLE forever — the live half of the UNAVAILABLE -> BadNoCommunication mapping.
|
||||
-->
|
||||
<MTConnectDevices
|
||||
xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="urn:mtconnect.org:MTConnectDevices:2.0 http://schemas.mtconnect.org/schemas/MTConnectDevices_2.0.xsd">
|
||||
<Header creationTime="2026-07-24T00:00:00Z" sender="otopcua-fixture" instanceId="0" version="2.0" assetBufferSize="128" assetCount="0" bufferSize="16384"/>
|
||||
<Devices>
|
||||
<!--
|
||||
The Adapters block in agent.cfg is keyed by this Device's NAME (OtFixtureCnc). Rename
|
||||
one and you must rename the other, or the Agent starts with no adapter attached and
|
||||
every observation stays UNAVAILABLE.
|
||||
-->
|
||||
<Device id="otfixture" name="OtFixtureCnc" uuid="otopcua-mtconnect-fixture">
|
||||
<Description manufacturer="OtOpcUa" model="IntegrationFixture" serialNumber="fixture-001">MTConnect integration-test fixture</Description>
|
||||
<DataItems>
|
||||
<!-- Device-level data items, declared OUTSIDE any component: the walker must not drop these. -->
|
||||
<DataItem category="EVENT" id="fixture_avail" name="Favail" type="AVAILABILITY"/>
|
||||
<DataItem category="EVENT" id="fixture_asset_changed" type="ASSET_CHANGED"/>
|
||||
<DataItem category="EVENT" id="fixture_asset_removed" type="ASSET_REMOVED"/>
|
||||
</DataItems>
|
||||
<Components>
|
||||
<Axes id="fixture_axes" name="Axes">
|
||||
<Components>
|
||||
<Linear id="fixture_x" name="X">
|
||||
<DataItems>
|
||||
<!-- SAMPLE + units -> Float64. Driven as a sinusoid, so its value CHANGES between reads. -->
|
||||
<DataItem category="SAMPLE" id="fixture_x_pos" name="Xact" type="POSITION" subType="ACTUAL" units="MILLIMETER" nativeUnits="MILLIMETER"/>
|
||||
<DataItem category="SAMPLE" id="fixture_x_load" name="Xload" type="LOAD" units="PERCENT" nativeUnits="PERCENT"/>
|
||||
<!-- CONDITION -> String + IsAlarm, whatever its type says. -->
|
||||
<DataItem category="CONDITION" id="fixture_x_travel" name="Xtravel" type="POSITION"/>
|
||||
</DataItems>
|
||||
</Linear>
|
||||
<Rotary id="fixture_c" name="C">
|
||||
<DataItems>
|
||||
<DataItem category="SAMPLE" id="fixture_c_speed" name="Cspeed" type="ROTARY_VELOCITY" subType="ACTUAL" units="REVOLUTION/MINUTE" nativeUnits="REVOLUTION/MINUTE"/>
|
||||
<!--
|
||||
TIME_SERIES -> Float64 ARRAY, with ArrayDim NULL (variable length).
|
||||
|
||||
There is deliberately no sampleCount attribute here, and there cannot be:
|
||||
`sampleCount` is an attribute of a TIME_SERIES *observation*, not of a
|
||||
DataItem declaration. A real Agent rejects it outright: it 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, so the tag simply vanishes from /probe (verified live
|
||||
against mtconnect/agent 2.7.0.12). MTConnectDataTypeInference therefore only
|
||||
ever sees sampleCount = null from a real Agent, and stamps ArrayDim = null.
|
||||
`sampleRate` IS a valid DataItem attribute and is kept.
|
||||
-->
|
||||
<DataItem category="SAMPLE" id="fixture_c_temp_series" name="Ctemps" type="TEMPERATURE" units="CELSIUS" nativeUnits="CELSIUS" representation="TIME_SERIES" sampleRate="100"/>
|
||||
</DataItems>
|
||||
</Rotary>
|
||||
</Components>
|
||||
</Axes>
|
||||
<Controller id="fixture_controller" name="Controller">
|
||||
<DataItems>
|
||||
<!-- Controlled vocabulary EVENT -> String. -->
|
||||
<DataItem category="EVENT" id="fixture_mode" name="Cmode" type="CONTROLLER_MODE"/>
|
||||
</DataItems>
|
||||
<Components>
|
||||
<Path id="fixture_path" name="Path">
|
||||
<DataItems>
|
||||
<DataItem category="EVENT" id="fixture_execution" name="Pexec" type="EXECUTION"/>
|
||||
<!-- THE regression case: UPPER_SNAKE PART_COUNT must infer Int64, not String. -->
|
||||
<DataItem category="EVENT" id="fixture_partcount" name="Pcount" type="PART_COUNT"/>
|
||||
<DataItem category="EVENT" id="fixture_linenumber" name="Pline" type="LINE_NUMBER"/>
|
||||
<DataItem category="EVENT" id="fixture_program" name="Pprogram" type="PROGRAM"/>
|
||||
<!-- Free-text EVENT the adapter NEVER feeds -> stays UNAVAILABLE. -->
|
||||
<DataItem category="EVENT" id="fixture_block" name="Pblock" type="BLOCK"/>
|
||||
<!-- DATA_SET representation demotes to String even though the Agent sends key=value entries. -->
|
||||
<DataItem category="EVENT" id="fixture_varset" name="Pvars" type="VARIABLE" representation="DATA_SET"/>
|
||||
<DataItem category="CONDITION" id="fixture_logic" name="Plogic" type="LOGIC_PROGRAM"/>
|
||||
</DataItems>
|
||||
</Path>
|
||||
</Components>
|
||||
</Controller>
|
||||
</Components>
|
||||
</Device>
|
||||
</Devices>
|
||||
</MTConnectDevices>
|
||||
@@ -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 `<Agent>` 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.
|
||||
@@ -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 `<timestamp>|<key>|<value>[|<key>|<value>...]`, where <key> 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 <ms>` 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 <Entry key=...> 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: <key>|<level>|<nativeCode>|<nativeSeverity>|<qualifier>|<text>."""
|
||||
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: <key>|<sampleCount>|<sampleRate>|<v1> <v2> ...
|
||||
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())
|
||||
@@ -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
|
||||
}
|
||||
+79
@@ -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
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
using System.Net.Http;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability probe for a live MTConnect Agent (the <c>mtconnect/agent</c> container in
|
||||
/// <c>Docker/docker-compose.yml</c>, or a real Agent on a machine tool). Reads
|
||||
/// <c>MTCONNECT_AGENT_ENDPOINT</c> (default <c>http://10.100.0.35:5000</c> — the shared Docker
|
||||
/// host) and issues ONE <c>GET {endpoint}/probe</c> at fixture construction. Each test checks
|
||||
/// <see cref="SkipReason"/> and calls <c>Assert.Skip</c> when the Agent was unreachable, so a
|
||||
/// dev box with no fixture running still passes <c>dotnet test</c> cleanly — the same pattern
|
||||
/// as <c>ModbusSimulatorFixture</c> / <c>S7SimulatorFixture</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The probe is an HTTP round-trip, not a TCP connect</b> — 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 <c>Devices.xml</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The probe response is kept</b> (<see cref="ProbeDocument"/>) and is the source of
|
||||
/// truth every assertion in this suite is written against. Nothing here may hard-code a
|
||||
/// DataItem id: the seeded <c>Devices.xml</c> 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A collection fixture, so the probe runs once per session</b> rather than once per
|
||||
/// test: against a firewalled endpoint each attempt costs the full timeout.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectAgentFixture : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The shared Docker host (see CLAUDE.md "Docker Workflow"); port 5000 is the Agent's own default.</summary>
|
||||
private const string DefaultEndpoint = "http://10.100.0.35:5000";
|
||||
|
||||
private const string EndpointEnvVar = "MTCONNECT_AGENT_ENDPOINT";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.";
|
||||
|
||||
/// <summary>Gets the Agent's base URI, exactly as an authored <c>agentUri</c> would carry it.</summary>
|
||||
public string AgentUri { get; }
|
||||
|
||||
/// <summary>Gets the skip reason when the Agent is unreachable or unusable; otherwise <c>null</c>.</summary>
|
||||
public string? SkipReason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Agent's own <c>/probe</c> response, parsed as XML — <c>null</c> exactly when
|
||||
/// <see cref="SkipReason"/> is set. Tests cross-reference discovery output against this so
|
||||
/// their assertions describe the Agent's declared model rather than a hard-coded fixture.
|
||||
/// </summary>
|
||||
public XDocument? ProbeDocument { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="MTConnectAgentFixture"/> class.</summary>
|
||||
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 <MTConnectDevices>. {HowToStart}";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ProbeDocument = document;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SkipReason = $"MTConnect Agent at {AgentUri} is unreachable: {ex.GetType().Name}: {ex.Message}. {HowToStart}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets every <c>DataItem</c> the Agent declares, flattened out of
|
||||
/// <see cref="ProbeDocument"/> with the attributes the type inference consumes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read straight off the XML with LINQ-to-XML rather than through the driver's own
|
||||
/// <c>MTConnectProbeParser</c>: 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.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<DeclaredDataItem> 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)];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>DataItem</c> as the live Agent declares it in its <c>/probe</c> response — the
|
||||
/// expectation side of every discovery assertion in this suite.
|
||||
/// </summary>
|
||||
/// <param name="Id">The <c>id</c> attribute: the observation correlation key, and what discovery must stamp as <c>FullName</c>.</param>
|
||||
/// <param name="Name">The <c>name</c> attribute when present: cosmetic, and what discovery must use as the browse name.</param>
|
||||
/// <param name="Category">The <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.</param>
|
||||
/// <param name="Type">The <c>type</c> attribute in the probe document's UPPER_SNAKE spelling (<c>PART_COUNT</c>, not <c>PartCount</c>).</param>
|
||||
/// <param name="Units">The <c>units</c> attribute when present.</param>
|
||||
/// <param name="Representation">The <c>representation</c> attribute when present (<c>TIME_SERIES</c>, <c>DATA_SET</c>, …).</param>
|
||||
/// <param name="InComposition">
|
||||
/// <c>true</c> when this DataItem is declared under a <c><Compositions></c> element rather
|
||||
/// than under a component's own <c><DataItems></c>. The driver's discovery walk recurses
|
||||
/// <c><Components></c> 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.
|
||||
/// </param>
|
||||
/// <param name="InAgentSelfModel">
|
||||
/// <c>true</c> when this DataItem belongs to the Agent's own <c><Agent></c> self-model
|
||||
/// (connection status, observation update rate, adapter URI, …) rather than to a
|
||||
/// <c><Device></c>. Every MTConnect 2.x Agent publishes one, and its update-rate SAMPLEs
|
||||
/// move continuously <b>whether or not any adapter is attached</b> — 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.
|
||||
/// </param>
|
||||
public sealed record DeclaredDataItem(
|
||||
string Id,
|
||||
string? Name,
|
||||
string Category,
|
||||
string Type,
|
||||
string? Units,
|
||||
string? Representation,
|
||||
bool InComposition,
|
||||
bool InAgentSelfModel);
|
||||
|
||||
/// <summary>Collection definition so the reachability probe runs once per test session.</summary>
|
||||
[Xunit.CollectionDefinition(Name)]
|
||||
public sealed class MTConnectAgentCollection : Xunit.ICollectionFixture<MTConnectAgentFixture>
|
||||
{
|
||||
/// <summary>The collection name every test class in this suite joins.</summary>
|
||||
public const string Name = "MTConnectAgent";
|
||||
}
|
||||
+697
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The MTConnect driver against a <b>real</b> MTConnect Agent (the <c>mtconnect/agent</c>
|
||||
/// fixture in <c>Docker/</c>, or a live Agent on a machine tool via
|
||||
/// <c>MTCONNECT_AGENT_ENDPOINT</c>). Everything else in the driver's test surface runs on canned
|
||||
/// XML; this suite is the only thing that touches a socket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What only a live Agent can prove</b>, and therefore what every test here is aimed at:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>UPPER_SNAKE type spellings.</b> A <c>/probe</c> document writes
|
||||
/// <c>DataItem@type</c> as <c>PART_COUNT</c>; a streams document names the same
|
||||
/// concept <c>PartCount</c>. An earlier revision of the inference matched only the
|
||||
/// PascalCase spelling, typed every real Agent's part counter as
|
||||
/// <see cref="DriverDataType.String"/>, and passed every unit test — because the
|
||||
/// hand-authored fixtures used the streams spelling in the probe document too.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b><c>name</c> differs from <c>id</c>.</b> 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.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>A real <c>multipart/x-mixed-replace</c> stream.</b> 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.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Assertions are structural, never by literal id.</b> Every expectation is computed from
|
||||
/// the Agent's OWN <c>/probe</c> response (<see cref="MTConnectAgentFixture.ProbeDocument"/>),
|
||||
/// so the suite survives an edit to the seeded <c>Devices.xml</c> and can be pointed at a
|
||||
/// real machine tool. A test whose subject the Agent does not declare (no CONDITION, no
|
||||
/// PART_COUNT) <c>Assert.Skip</c>s rather than passing vacuously.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two things this suite deliberately does NOT cover, with reasons.</b>
|
||||
/// (a) <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
|
||||
/// pin it, but a modern Agent cannot be made to produce it: <c>mtconnect/agent</c> 2.7
|
||||
/// answers an unknown device with <b>404</b> and an out-of-range sequence with <b>400</b>,
|
||||
/// each carrying a well-formed <c>MTConnectError</c> body (verified live). The under-200
|
||||
/// shape belongs to older/third-party Agents and stays canned-only.
|
||||
/// (b) <b>Writes.</b> MTConnect is a read-only source protocol and the driver implements no
|
||||
/// <c>IWritable</c>; the fixture Agent runs <c>AllowPut = false</c> to match.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(MTConnectAgentCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Driver", "MTConnect")]
|
||||
public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const int TestTimeoutMs = 90_000;
|
||||
|
||||
/// <summary>Canonical <c>Opc.Ua.StatusCodes</c> numerics, restated so the assertion names the wire value a client sees.</summary>
|
||||
private const uint Good = 0x00000000u;
|
||||
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>How long a subscription test waits for the live <c>/sample</c> stream to move a value.</summary>
|
||||
private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
// ---- reachability ----
|
||||
|
||||
/// <summary>
|
||||
/// The AdminUI Test Connect path against a live Agent: <see cref="MTConnectDriverProbe"/>
|
||||
/// must go green and report a latency.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Falsifiability control for <see cref="Probe_reports_the_live_agent_reachable"/>: the same
|
||||
/// probe against a port nothing is listening on must go RED. Without this, a probe that
|
||||
/// returned <c>Ok = true</c> unconditionally would satisfy the green test forever.
|
||||
/// </summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>DiscoverAsync</c> 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.
|
||||
/// </summary>
|
||||
[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 <Components>, 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The regression this whole fixture exists for.</b> An <c>EVENT</c> whose probe-document
|
||||
/// type is the UPPER_SNAKE <c>PART_COUNT</c> / <c>LINE_NUMBER</c> must infer
|
||||
/// <see cref="DriverDataType.Int64"/>. Matching only the streams document's PascalCase
|
||||
/// <c>PartCount</c> types it <see cref="DriverDataType.String"/> — green in every unit test,
|
||||
/// wrong on every real Agent.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <c>SAMPLE</c> is a measured quantity by definition of the category, so it must infer
|
||||
/// <see cref="DriverDataType.Float64"/> — scalar for an ordinary sample, an array for a
|
||||
/// <c>TIME_SERIES</c>.
|
||||
/// </summary>
|
||||
[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 ?? "<none>"})");
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <c>CONDITION</c> is a state word, never a number, whatever its <c>type</c> says — and it
|
||||
/// is the one category discovery flags as an alarm.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The <c>FullName</c>-vs-browse-name split, on the shape that makes it visible.</b> The
|
||||
/// driver-side reference must be the DataItem's <c>id</c> (the observation correlation key)
|
||||
/// while the browse name is its <c>name</c>. 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>ReadAsync</c> returns live, coerced values off the Agent's <c>/current</c> snapshot for
|
||||
/// the numeric samples it is currently reporting.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent's literal <c>UNAVAILABLE</c> must map to <c>BadNoCommunication</c> — not to a
|
||||
/// Good empty string, and not to the "no value yet" code, which means something different to
|
||||
/// an operator.
|
||||
/// </summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>
|
||||
/// <b>The live streaming proof.</b> A real <c>multipart/x-mixed-replace</c> body, with real
|
||||
/// boundaries and real heartbeats, must drive <c>OnDataChange</c> — and must drive it with a
|
||||
/// value that has actually MOVED since the primed <c>/current</c> 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.
|
||||
/// </summary>
|
||||
[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<DataChangeEventArgs>();
|
||||
var moved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var baseline = new ConcurrentDictionary<string, double>(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 ----
|
||||
|
||||
/// <summary>
|
||||
/// The shape a deployed driver actually serves: the artifact injects <c>RawTags</c>
|
||||
/// (RawPath + a <c>TagConfig</c> blob naming the DataItem id), and the driver must read and
|
||||
/// publish <b>by RawPath</b>. A driver that answered under its own DataItem id would be
|
||||
/// dropped silently by <c>DriverHostActor</c>'s RawPath-keyed routing table, with every unit
|
||||
/// test still green.
|
||||
/// </summary>
|
||||
[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<DataChangeEventArgs>();
|
||||
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);
|
||||
|
||||
/// <summary>The minimal Test Connect config: the probe reads nothing but <c>agentUri</c>.</summary>
|
||||
private static string ProbeConfig(string agentUri) =>
|
||||
JsonSerializer.Serialize(new { agentUri });
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<MTConnectTagDefinition>? 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);
|
||||
|
||||
/// <summary>Initializes a driver against the live Agent with the supplied authored tags.</summary>
|
||||
private async Task<LiveDriver> LiveDriverAsync(IEnumerable<MTConnectTagDefinition> tags)
|
||||
{
|
||||
var options = LiveOptions();
|
||||
var driver = new MTConnectDriver(options, "mtconnect-live");
|
||||
await driver.InitializeAsync(ConfigJson(options, tags), Ct);
|
||||
|
||||
return new LiveDriver(driver);
|
||||
}
|
||||
|
||||
/// <summary>Initializes a driver and captures one full <c>DiscoverAsync</c> pass.</summary>
|
||||
private async Task<DiscoveryCapture> DiscoverAsync()
|
||||
{
|
||||
await using var live = await LiveDriverAsync([]);
|
||||
|
||||
var capture = new DiscoveryCapture();
|
||||
await live.Driver.DiscoverAsync(capture, Ct);
|
||||
|
||||
return capture;
|
||||
}
|
||||
|
||||
/// <summary>The single captured variable for a DataItem id, asserting it was streamed exactly once.</summary>
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>The Agent's <c>/current</c> snapshot, fetched straight over HTTP.</summary>
|
||||
/// <remarks>
|
||||
/// Read outside the driver on purpose: several assertions need to know what the Agent is
|
||||
/// reporting <i>right now</i> in order to build their expectation, and computing that with
|
||||
/// the parser under test would only prove the code agrees with itself.
|
||||
/// </remarks>
|
||||
private async Task<XDocument> CurrentAsync()
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
var body = await http.GetStringAsync($"{agent.AgentUri}/current", Ct);
|
||||
|
||||
return XDocument.Parse(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The Agent's own self-model is excluded, and that exclusion is load-bearing.</b> Every
|
||||
/// MTConnect 2.x Agent publishes an <c><Agent></c> device whose
|
||||
/// <c>OBSERVATION_UPDATE_RATE</c> / <c>ASSET_UPDATE_RATE</c> SAMPLEs tick continuously
|
||||
/// regardless of whether any adapter is attached. Including them made
|
||||
/// <see cref="Subscribe_delivers_a_changed_value_from_the_live_sample_stream"/> pass with the
|
||||
/// fixture's data source deliberately stopped (verified) — it was proving the Agent's own
|
||||
/// heartbeat, not the device stream. Restricted to <c><Device></c>-owned samples, an
|
||||
/// Agent with no live source yields an empty set and the test skips with an actionable
|
||||
/// message instead.
|
||||
/// </remarks>
|
||||
private async Task<IReadOnlyList<string>> 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)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope guard: <see cref="MTConnectDriver"/> is not disposable (its teardown is
|
||||
/// <c>ShutdownAsync</c>), and a test that failed mid-way would otherwise leave a live
|
||||
/// <c>/sample</c> long poll running against the fixture for the rest of the session.
|
||||
/// </summary>
|
||||
private sealed class LiveDriver(MTConnectDriver driver) : IAsyncDisposable
|
||||
{
|
||||
public MTConnectDriver Driver { get; } = driver;
|
||||
|
||||
public async ValueTask DisposeAsync() => await Driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
|
||||
exercise wire-level behavior against a live Agent — the analyzer's documented intentional
|
||||
case. Mirrors the NoWarn the MTConnect unit suite carries for the same reason. -->
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- The fixture (compose + agent.cfg + Devices.xml + adapter.py) beside the test binaries, so
|
||||
the skip message can point at a compose file that is actually next to the runner — the
|
||||
same convention the Modbus / S7 / AB fixtures use. -->
|
||||
<ItemGroup>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user