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; } = [];
|
||||
}
|
||||
Reference in New Issue
Block a user