feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)

This commit is contained in:
Joseph Doherty
2026-07-24 16:41:21 -04:00
parent 9a67ed918a
commit 13dd9fcd70
3 changed files with 1053 additions and 5 deletions
@@ -43,10 +43,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/>,
/// <see cref="IReadable"/> and <see cref="ISubscribable"/>. <c>ITagDiscovery</c> (Task 12)
/// and <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on top of
/// the state this class already caches — the probe model, the Agent <c>instanceId</c>, and
/// the observation index.
/// <see cref="IReadable"/>, <see cref="ISubscribable"/> and <see cref="ITagDiscovery"/>.
/// <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on top of the
/// state this class already caches — the probe model, the Agent <c>instanceId</c>, and the
/// observation index. There is deliberately no <c>IWritable</c>: MTConnect is a read-only
/// protocol, which is also why every discovered leaf is
/// <see cref="SecurityClassification.ViewOnly"/>.
/// </para>
/// <para>
/// <b>The read path never writes the shared observation index</b> — see
@@ -54,7 +56,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// (<see cref="RunSampleStreamAsync"/>).
/// </para>
/// </remarks>
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery
{
/// <summary>
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
@@ -733,6 +735,240 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable
_driverInstanceId, ours.DiagnosticId, stopStream ? " and stopped the shared /sample stream" : string.Empty);
}
// ---- ITagDiscovery: the /probe device model, streamed as folders + variables ----
/// <inheritdoc/>
/// <remarks>
/// <b>This one member is the whole browse opt-in.</b> The Wave-0 universal
/// <c>DiscoveryDriverBrowser</c> turns any driver that answers <c>true</c> here into a live
/// address picker by capturing <see cref="DiscoverAsync"/> — so this driver ships no browser
/// code, no per-driver picker component, and no entry in the browser's browse-patch table
/// (its discovery is unconditional; there is no "enable browse" option to turn on first).
/// It is answered on an <b>un-initialized</b> instance without dialling anything, because
/// <c>CanBrowse</c> constructs a throwaway driver purely to read it — which is also why the
/// constructor opens nothing.
/// </remarks>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc/>
/// <remarks>
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
/// complete device model within the single call — <c>/probe</c> is one synchronous request
/// that either answers with the whole hierarchy or fails — so nothing fills in asynchronously
/// after connect and a settle loop would only re-fetch an identical answer. (The one thing
/// that <i>does</i> change the model is an Agent restart, and that is
/// <c>IRediscoverable</c>'s job in Task 13, not a retry cadence's.)
/// </remarks>
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc/>
/// <remarks>
/// <para>
/// Streams the Agent's <c>/probe</c> hierarchy verbatim: one folder per <c>Device</c>,
/// one per nested <c>Component</c> to arbitrary depth, and one variable per
/// <c>DataItem</c> under whichever container declares it — <b>including the data items a
/// device declares directly</b>, outside any component (an availability item almost
/// always lives there, and a walker that only recursed <c>Components</c> would silently
/// drop it).
/// </para>
/// <para>
/// <b><see cref="DriverAttributeInfo.FullName"/> is the <c>DataItem@id</c>, never the
/// browse name.</b> The universal browser commits a browsed leaf's <c>FullName</c>
/// straight into <c>TagConfig.FullName</c>, and that is the key
/// <see cref="ReadAsync"/> and the <c>/sample</c> pump resolve an observation against
/// (<see cref="MTConnectObservationIndex"/> is keyed by <c>DataItem@id</c>). Committing
/// the <c>name</c> instead would produce a picker that looks right and authors tags that
/// can never report a value. The browse <i>name</i> — cosmetic — prefers the DataItem's
/// <c>name</c> and falls back to its <c>id</c>, which for this protocol is the common
/// case rather than an edge case.
/// </para>
/// <para>
/// <b>The type shape comes from <see cref="MTConnectDataTypeInference.Infer"/>'s returned
/// record, in full</b> — <see cref="MTConnectInferredType.IsArray"/> and
/// <see cref="MTConnectInferredType.ArrayDim"/> included. Recomputing the array shape
/// locally from <c>representation</c>/<c>sampleCount</c> is the tempting shortcut and it
/// is wrong: the inference also demotes <c>DATA_SET</c>/<c>TABLE</c> to
/// <see cref="DriverDataType.String"/> even on a <c>SAMPLE</c>, and honours
/// <c>TIME_SERIES</c> only on a <c>SAMPLE</c>. The AdminUI typed editor calls the same
/// function, so any divergence here makes the picker and the editor disagree about one
/// data item. The <c>type</c> attribute is passed <b>verbatim</b> for the same reason:
/// the probe document spells it UPPER_SNAKE (<c>PART_COUNT</c>) where the streams
/// document uses PascalCase, and the inference is separator-insensitive precisely to
/// bridge the two — pre-normalising it here would defeat that.
/// </para>
/// <para>
/// <b><see cref="DriverAttributeInfo.IsAlarm"/> is this seam's job</b>, deliberately not
/// the inference's: a <c>CONDITION</c> data item is an alarm.
/// <c>IVariableHandle.MarkAsAlarmCondition</c> is <i>not</i> called — that annotation
/// needs the driver-side refs of an alarm's sibling attributes (in-alarm flag, priority,
/// ack target), and an MTConnect condition has none: it is one text observation whose
/// value is the state word. Core's generic node manager enriches on the flag alone.
/// </para>
/// <para>
/// <b>The model comes from <see cref="GetOrFetchProbeModelAsync"/>, never from
/// <see cref="CachedProbeModel"/>.</b> <see cref="FlushOptionalCachesAsync"/> may have
/// dropped the cache under memory pressure; reading the field directly would make a
/// flushed driver browse as an Agent with no data items, permanently.
/// </para>
/// <para>
/// <b>This throws, and that is the opposite of <see cref="ReadAsync"/>'s posture on
/// purpose.</b> A read has a per-reference Bad status code to report a failure in-band; a
/// discovery stream has no such channel, so the only way to "fail quietly" would be to
/// emit an empty tree — indistinguishable from an Agent that genuinely declares nothing,
/// and read by an operator in the picker as a working connection to an empty machine
/// (#485: empty is not an answer). Both production callers handle the throw: the
/// universal browser surfaces it as a failed browse, and <c>DriverInstanceActor</c> logs
/// it and retries on its rediscover tick.
/// </para>
/// <para>
/// <b>Nothing here writes the observation index.</b> Discovery is a pure read of the
/// device model — the <c>/sample</c> pump remains its sole writer.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <c>null</c>.</exception>
/// <exception cref="InvalidOperationException">
/// The driver is not initialized, or it was torn down while the <c>/probe</c> fetch was in
/// flight (see <see cref="GetOrFetchProbeModelAsync"/>).
/// </exception>
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var model = await GetOrFetchProbeModelAsync(cancellationToken).ConfigureAwait(false);
// Read once: a concurrent re-initialize must not scope half this walk to one device and half
// to another.
var deviceScope = _options.DeviceName?.Trim();
var devices = 0;
var variables = 0;
foreach (var device in model.Devices)
{
if (device is null || !InDeviceScope(device, deviceScope))
{
continue;
}
devices++;
var name = FolderName(device.Name, device.Id);
variables += StreamContainer(device, builder.Folder(name, name));
}
_logger.LogDebug(
"MTConnect driver {DriverInstanceId} streamed {DeviceCount} device(s) and {VariableCount} data item(s) from {AgentUri}'s /probe model{DeviceScope}.",
_driverInstanceId,
devices,
variables,
_options.AgentUri,
deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')");
}
/// <summary>
/// Streams one device/component's own data items into <paramref name="scope"/>, then recurses
/// into each nested component under a folder of its own. Returns how many variables it
/// streamed, for the diagnostic tally.
/// </summary>
/// <remarks>
/// Walks the tree rather than using <c>AllDataItems()</c>: that helper flattens, and the
/// folder structure — which item belongs to which component — is precisely what a browse
/// picker exists to show. Recursion depth is bounded by the parser, which refuses a probe
/// document nested beyond its own limit.
/// </remarks>
private static int StreamContainer(IMTConnectComponentContainer container, IAddressSpaceBuilder scope)
{
var streamed = 0;
foreach (var dataItem in container.DataItems)
{
// An id-less data item cannot be read, subscribed, or committed as a tag — there would be
// nothing to key it by. The parser already requires the attribute; this is the guard for a
// model built any other way.
if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id))
{
continue;
}
// Every field the DataItem declares, straight through — the array shape is READ OFF the
// result, never recomputed here. See the DiscoverAsync remarks.
var inferred = MTConnectDataTypeInference.Infer(
dataItem.Category,
dataItem.Type,
dataItem.Units,
dataItem.Representation,
dataItem.SampleCount);
var browseName = string.IsNullOrWhiteSpace(dataItem.Name) ? dataItem.Id : dataItem.Name;
scope.Variable(
browseName,
browseName,
new DriverAttributeInfo(
FullName: dataItem.Id,
DriverDataType: inferred.DataType,
IsArray: inferred.IsArray,
ArrayDim: inferred.ArrayDim,
// MTConnect is read-only and this driver implements no IWritable, so the tier
// that is read-only from OPC UA is the only honest one.
SecurityClass: SecurityClassification.ViewOnly,
// Historization is authored per tag, never inferred from a device model.
IsHistorized: false,
IsAlarm: IsCondition(dataItem.Category)));
streamed++;
}
foreach (var component in container.Components)
{
if (component is null)
{
continue;
}
var name = FolderName(component.Name, component.Id);
streamed += StreamContainer(component, scope.Folder(name, name));
}
return streamed;
}
/// <summary>
/// Whether a device is in this instance's configured scope. An unset scope is agent-wide.
/// </summary>
/// <remarks>
/// <para>
/// Matched against the device's <c>name</c> <b>or</b> its <c>id</c> — a device model may
/// omit the name, and its id is then what an operator would put in the config and what a
/// scoped Agent URL would carry.
/// </para>
/// <para>
/// <b>Belt-and-braces, not dead code.</b> The production client already narrows the
/// request to <c>{AgentUri}/{DeviceName}/probe</c>, so a conforming Agent answers with the
/// one device anyway. But an Agent (or an intermediary) that ignored the path segment and
/// answered agent-wide would otherwise publish every other machine's data items into an
/// instance scoped to one, and the operator's only evidence would be a picker full of ids
/// they never configured. Matching is <b>exact</b> for the same reason the URL is: a
/// differently-cased device name is not the device the Agent would have served.
/// </para>
/// </remarks>
private static bool InDeviceScope(MTConnectDevice device, string? deviceScope) =>
string.IsNullOrEmpty(deviceScope)
|| string.Equals(device.Name, deviceScope, StringComparison.Ordinal)
|| string.Equals(device.Id, deviceScope, StringComparison.Ordinal);
/// <summary>The folder name for a device/component: its <c>name</c>, or its <c>id</c> when it declares none.</summary>
private static string FolderName(string? name, string id) => string.IsNullOrWhiteSpace(name) ? id : name;
/// <summary>
/// Whether a data item's category makes it an alarm. Lives here rather than in
/// <see cref="MTConnectDataTypeInference"/> because that function answers a <i>type</i>
/// question; this is an address-space one.
/// </summary>
private static bool IsCondition(string? category) =>
category.AsSpan().Trim().Equals("CONDITION", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Starts the shared <c>/sample</c> pump, if there is anything to pump and anything to pump
/// from. <b>Caller must hold <see cref="_subscriptionLock"/>.</b>
@@ -0,0 +1,132 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// An <see cref="IAddressSpaceBuilder"/> that records the tree a driver streams into it, so
/// <c>DiscoverAsync</c> can be asserted on <b>shape</b> (which node landed under which folder)
/// and not merely on a flat list of leaves.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a reference to the shipped capturing builder.</b> Two
/// production capturing builders exist — <c>Commons.Browsing.CapturingAddressSpaceBuilder</c>
/// (universal browser) and <c>Runtime.Drivers.CapturingAddressSpaceBuilder</c> (deploy-time
/// discovery) — but this suite deliberately references only the driver and its
/// <c>.Contracts</c>, so neither is reachable without pulling the whole server stack into a
/// driver unit-test project. Both also flatten differently from what these tests must prove:
/// the browser's node id for a folder is a synthetic path while a leaf's id is its
/// <c>FullName</c>, which is exactly the conflation a "did the device-level data item land in
/// the device folder?" assertion needs to see through.
/// </para>
/// <para>
/// <b>Deliberately not thread-safe.</b> A driver streams discovery on one caller; recording
/// behind a lock would hide a driver that fanned out onto other threads.
/// </para>
/// </remarks>
internal sealed class CapturingBuilder : IAddressSpaceBuilder
{
private readonly State _state;
private readonly string _path;
/// <summary>Creates a root builder — the scope a driver's <c>DiscoverAsync</c> is handed.</summary>
public CapturingBuilder()
{
_state = new State();
_path = string.Empty;
}
private CapturingBuilder(State state, string path)
{
_state = state;
_path = path;
}
/// <summary>Every folder streamed, in call order, with the slash-joined path it landed at.</summary>
public IReadOnlyList<CapturedFolder> Folders => _state.Folders;
/// <summary>Every variable streamed, in call order, with the folder path it landed under.</summary>
public IReadOnlyList<CapturedVariable> Variables => _state.Variables;
/// <summary>Every property streamed, with the scope path it was added to.</summary>
public IReadOnlyList<CapturedProperty> Properties => _state.Properties;
/// <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 CapturingBuilder(_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 CapturedVariableHandle(captured);
}
/// <inheritdoc/>
public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
_state.Properties.Add(new CapturedProperty(_path, browseName, dataType, value));
private sealed class State
{
public List<CapturedFolder> Folders { get; } = [];
public List<CapturedVariable> Variables { get; } = [];
public List<CapturedProperty> Properties { get; } = [];
}
private sealed class CapturedVariableHandle(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)
{
// Nothing to record: no test in this suite drives an alarm transition through
// discovery, and a sink that threw would fail a driver that legitimately never
// pushes one.
}
}
}
}
/// <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 (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>
/// <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; } = [];
}
/// <summary>One property captured from a discovery stream.</summary>
/// <param name="ScopePath">Slash-joined path of the node it was added to.</param>
/// <param name="BrowseName">The property browse name.</param>
/// <param name="DataType">The property data type.</param>
/// <param name="Value">The property value.</param>
internal sealed record CapturedProperty(string ScopePath, string BrowseName, DriverDataType DataType, object? Value);
@@ -0,0 +1,680 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Task 12 — <see cref="MTConnectDriver"/>'s <see cref="ITagDiscovery"/> half: the
/// <c>/probe</c> device model streamed into an <see cref="IAddressSpaceBuilder"/> as
/// Device → Component → DataItem, and the one-member browse opt-in
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) the Wave-0 universal browser keys off.
/// </summary>
/// <remarks>
/// <para>
/// <b>The load-bearing assertion is <c>FullName == DataItem@id</c>.</b> The universal
/// browser commits a browsed leaf's <see cref="DriverAttributeInfo.FullName"/> straight into
/// <c>TagConfig.FullName</c>, and that is the key <c>ReadAsync</c> / the <c>/sample</c> pump
/// resolve an observation against (<see cref="MTConnectObservationIndex"/> is keyed by
/// <c>DataItem@id</c>). Streaming the browse <i>name</i> instead would produce a picker that
/// looks perfect and commits tags that can never report a value — every one of them stuck at
/// <c>BadWaitingForInitialData</c> behind a Healthy driver.
/// </para>
/// <para>
/// <b>The second load-bearing assertion is the type shape.</b> Discovery must stamp exactly
/// what <see cref="MTConnectDataTypeInference"/> returns, including the array shape — the
/// AdminUI typed editor calls the same function, so any local recomputation here makes the
/// picker and the editor disagree about the same data item. <c>PART_COUNT → Int64</c> is
/// called out on its own because the probe document's UPPER_SNAKE spelling is the case a
/// PascalCase-only match silently types as <c>String</c>.
/// </para>
/// </remarks>
public sealed class MTConnectDiscoverTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>Every DataItem id the canned <c>Fixtures/probe.xml</c> declares.</summary>
private static readonly string[] FixtureDataItemIds =
[
"dev1_avail",
"dev1_pos",
"dev1_vibration_ts",
"dev1_partcount",
"dev1_execution",
"dev1_program",
"dev1_system_cond",
];
private static MTConnectDriverOptions Opts(string? deviceName = null) =>
new()
{
AgentUri = AgentUri,
DeviceName = deviceName,
// Discovery is a pure read of the /probe device model: it must enumerate what the Agent
// declares, NOT what an operator has already authored. Left empty on purpose so a driver
// that streamed its authored tag table instead could not pass.
Tags = [],
};
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
MTConnectDriverOptions? options = null, MTConnectProbeModel? probe = null)
{
var client = CannedAgentClient.FromFixtures();
if (probe is not null)
{
client.Probe = probe;
}
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
MTConnectDriverOptions? options = null, MTConnectProbeModel? probe = null)
{
var (driver, client) = NewDriver(options, probe);
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (driver, client);
}
private static async Task<CapturingBuilder> DiscoverAsync(MTConnectDriver driver)
{
var builder = new CapturingBuilder();
await driver.DiscoverAsync(builder, TestContext.Current.CancellationToken);
return builder;
}
// ---- the plan's headline test ----
/// <summary>
/// The whole contract in one place: the fixture's Device → Controller → Path tree reaches the
/// builder, the CONDITION data item is present under its own <c>id</c>, it types as
/// <see cref="DriverDataType.String"/>, and the driver declares the browse opt-in the
/// universal browser gates on.
/// </summary>
[Fact]
public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev1_system_cond");
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
driver.SupportsOnlineDiscovery.ShouldBeTrue();
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
}
// ---- tree shape ----
/// <summary>
/// The folder tree mirrors the probe document exactly: one folder per Device (named by its
/// <c>name</c>), then one per nested Component to arbitrary depth, each under its own parent.
/// </summary>
[Fact]
public async Task Discover_materialises_a_folder_per_device_and_nested_component()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(
["VMC-3Axis", "VMC-3Axis/controller", "VMC-3Axis/controller/path"], ignoreOrder: true);
// Parentage, not just membership: a flat "three folders exist" assertion passes for a walker
// that streamed all three onto the root builder.
cap.Folders.Single(f => f.Path == "VMC-3Axis").ParentPath.ShouldBe(string.Empty);
cap.Folders.Single(f => f.Path == "VMC-3Axis/controller").ParentPath.ShouldBe("VMC-3Axis");
cap.Folders.Single(f => f.Path == "VMC-3Axis/controller/path").ParentPath.ShouldBe("VMC-3Axis/controller");
}
/// <summary>
/// <c>dev1_avail</c> hangs directly off <c>&lt;Device&gt;&lt;DataItems&gt;</c>, outside every
/// Component. A walker that only recursed <c>&lt;Components&gt;</c> would drop it silently, so
/// it is asserted to land in the <b>device</b> folder while the Path-owned items land in the
/// path folder.
/// </summary>
[Fact]
public async Task Discover_places_device_level_data_items_in_the_device_folder()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == "dev1_avail").ParentPath.ShouldBe("VMC-3Axis");
cap.Variables
.Where(v => v.Attr.FullName != "dev1_avail")
.ShouldAllBe(v => v.ParentPath == "VMC-3Axis/controller/path");
}
/// <summary>
/// Every DataItem the fixture declares is streamed exactly once, and every leaf's
/// <see cref="DriverAttributeInfo.FullName"/> is its <c>DataItem@id</c> — the read/subscribe
/// key, aligned with the browser's commit by construction.
/// </summary>
[Fact]
public async Task Discover_streams_every_data_item_once_with_fullname_equal_to_its_id()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Count.ShouldBe(FixtureDataItemIds.Length);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
}
/// <summary>
/// The <c>FullName</c>-is-the-<c>id</c> rule, asserted where the two can actually differ.
/// </summary>
/// <remarks>
/// <b>The fixture cannot prove this on its own</b> — its data items declare no <c>name</c>, so
/// <c>browseName</c> and <c>id</c> coincide and a driver that streamed the browse name as the
/// <c>FullName</c> would pass every fixture-based assertion in this file. This model gives
/// every data item a name that is disjoint from its id, so the two sets cannot be confused:
/// the commit key must be the id set, and the presentation must be the name set.
/// </remarks>
[Fact]
public async Task Discover_fullname_is_the_id_even_when_the_data_item_declares_a_different_name()
{
var probe = ModelWith(
new MTConnectDataItem("id_pos", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
new MTConnectDataItem("id_count", "PartsMade", "EVENT", "PART_COUNT", null, null, null, null),
new MTConnectDataItem("id_cond", "SystemHealth", "CONDITION", "SYSTEM", null, null, null, null),
new MTConnectDataItem(
"id_series", "Feed", "SAMPLE", "PATH_FEEDRATE", null, null, "TIME_SERIES", 10));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
cap.Variables.Select(v => v.Attr.FullName)
.ShouldBe(["id_pos", "id_count", "id_cond", "id_series"], ignoreOrder: true);
cap.Variables.Select(v => v.BrowseName)
.ShouldBe(["Xpos", "PartsMade", "SystemHealth", "Feed"], ignoreOrder: true);
// …and the id-keyed metadata still lands on the right leaf once the two differ.
cap.Variables.Single(v => v.Attr.FullName == "id_count").Attr.DriverDataType
.ShouldBe(DriverDataType.Int64);
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.FullName.ShouldBe("id_cond");
cap.Variables.Single(v => v.Attr.IsArray).Attr.FullName.ShouldBe("id_series");
}
// ---- type shape (must equal MTConnectDataTypeInference, not a local recomputation) ----
/// <summary>
/// The per-category defaults reach the builder unchanged: SAMPLE ⇒ Float64, controlled
/// vocabulary / free-text EVENT ⇒ String, CONDITION ⇒ String.
/// </summary>
[Theory]
[InlineData("dev1_pos", DriverDataType.Float64)]
[InlineData("dev1_avail", DriverDataType.String)]
[InlineData("dev1_execution", DriverDataType.String)]
[InlineData("dev1_program", DriverDataType.String)]
[InlineData("dev1_system_cond", DriverDataType.String)]
public async Task Discover_stamps_the_inferred_data_type(string dataItemId, DriverDataType expected)
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == dataItemId).Attr.DriverDataType.ShouldBe(expected);
}
/// <summary>
/// <c>PART_COUNT</c> is the live-only bug this driver's inference was already fixed for once:
/// the probe document spells it UPPER_SNAKE, so a match written against the Streams
/// document's <c>PartCount</c> types every real agent's part counter as
/// <see cref="DriverDataType.String"/> while a PascalCase unit test stays green. Asserted at
/// the discovery seam because that is where the probe spelling is actually read.
/// </summary>
[Fact]
public async Task Discover_types_the_upper_snake_PART_COUNT_event_as_Int64()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == "dev1_partcount").Attr.DriverDataType
.ShouldBe(DriverDataType.Int64);
}
/// <summary>
/// A <c>TIME_SERIES</c> SAMPLE is an array whose length is the probe-declared
/// <c>sampleCount</c>. The shape comes from <see cref="MTConnectDataTypeInference"/>'s
/// returned record — recomputing it inline at this seam is what makes discovery and the
/// AdminUI editor disagree.
/// </summary>
[Fact]
public async Task Discover_carries_the_time_series_array_shape_from_the_inference()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
var series = cap.Variables.Single(v => v.Attr.FullName == "dev1_vibration_ts").Attr;
series.DriverDataType.ShouldBe(DriverDataType.Float64);
series.IsArray.ShouldBeTrue();
series.ArrayDim.ShouldBe(10u);
}
/// <summary>
/// A <c>DATA_SET</c> SAMPLE demotes to <see cref="DriverDataType.String"/> and stays scalar —
/// a rule that lives <b>only</b> inside the inference. An inline
/// "IsArray = representation == TIME_SERIES, DataType = category == SAMPLE ? Float64 : String"
/// at this seam would type it Float64 and go Bad on every parse, and no fixture-only test
/// would notice.
/// </summary>
[Fact]
public async Task Discover_demotes_a_data_set_sample_to_string_per_the_inference()
{
var probe = ModelWith(
new MTConnectDataItem(
"dev9_toolset", null, "SAMPLE", "POSITION", null, "MILLIMETER", "DATA_SET", 4));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_toolset").Attr;
attr.DriverDataType.ShouldBe(DriverDataType.String);
attr.IsArray.ShouldBeFalse();
attr.ArrayDim.ShouldBeNull();
}
/// <summary>
/// <c>TIME_SERIES</c> is defined for SAMPLE only; on an EVENT the inference ignores it. Pins
/// the other half of "use the inference's answer, do not recompute the array shape".
/// </summary>
[Fact]
public async Task Discover_ignores_time_series_on_a_non_sample_per_the_inference()
{
var probe = ModelWith(
new MTConnectDataItem("dev9_msg", null, "EVENT", "MESSAGE", null, null, "TIME_SERIES", 8));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_msg").Attr;
attr.DriverDataType.ShouldBe(DriverDataType.String);
attr.IsArray.ShouldBeFalse();
attr.ArrayDim.ShouldBeNull();
}
// ---- the flags this seam owns ----
/// <summary>
/// <see cref="DriverAttributeInfo.IsAlarm"/> is the caller's job — the inference deliberately
/// does not compute it. Exactly the CONDITION data item carries it.
/// </summary>
[Fact]
public async Task Discover_marks_only_the_condition_data_item_as_an_alarm()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.Where(v => v.Attr.IsAlarm).Select(v => v.Attr.FullName).ShouldBe(["dev1_system_cond"]);
}
/// <summary>
/// MTConnect is a read-only protocol and this driver implements no <c>IWritable</c>, so every
/// browsed leaf is <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only
/// from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
/// never inferred from a device model.
/// </summary>
[Fact]
public async Task Discover_streams_every_leaf_view_only_and_not_historized()
{
var (driver, _) = await InitializedDriverAsync();
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.ViewOnly);
cap.Variables.ShouldAllBe(v => !v.Attr.IsHistorized);
cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
}
// ---- browse names ----
/// <summary>
/// The browse name is the DataItem's <c>name</c> when it declares one and its <c>id</c>
/// otherwise — the fixture's data items declare none, so the fallback is the normal case for
/// this protocol, not an edge case.
/// </summary>
[Fact]
public async Task Discover_browse_name_prefers_the_data_item_name_and_falls_back_to_its_id()
{
var probe = ModelWith(
new MTConnectDataItem("dev9_named", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
new MTConnectDataItem("dev9_unnamed", null, "EVENT", "PROGRAM", null, null, null, null));
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
var named = cap.Variables.Single(v => v.Attr.FullName == "dev9_named");
named.BrowseName.ShouldBe("Xpos");
named.DisplayName.ShouldBe("Xpos");
var unnamed = cap.Variables.Single(v => v.Attr.FullName == "dev9_unnamed");
unnamed.BrowseName.ShouldBe("dev9_unnamed");
// The browse name is presentation; the read key is not. Restated here because this is the
// one test where the two legitimately differ.
named.Attr.FullName.ShouldBe("dev9_named");
}
/// <summary>
/// A Device or Component with no <c>name</c> attribute falls back to its <c>id</c> rather
/// than materialising a blank folder.
/// </summary>
[Fact]
public async Task Discover_folder_names_fall_back_to_the_id_when_no_name_is_declared()
{
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(
"dev9",
null,
[
new MTConnectComponent(
"dev9_axes",
null,
[],
[new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null)]),
],
[]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["dev9", "dev9/dev9_axes"], ignoreOrder: true);
cap.Variables.Single().ParentPath.ShouldBe("dev9/dev9_axes");
}
// ---- DeviceName scoping ----
/// <summary>
/// With no <c>DeviceName</c> the driver is agent-wide and every device's subtree is streamed.
/// The control for the scoping test below — without it, a discovery that dropped the second
/// device for an unrelated reason would look like correct scoping.
/// </summary>
[Fact]
public async Task Discover_without_a_device_scope_streams_every_device()
{
var (driver, _) = await InitializedDriverAsync(probe: TwoDeviceModel());
var cap = await DiscoverAsync(driver);
cap.Folders.Where(f => f.ParentPath.Length == 0).Select(f => f.BrowseName)
.ShouldBe(["VMC-3Axis", "Lathe-2Axis"], ignoreOrder: true);
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev2_avail");
}
/// <summary>
/// With <c>DeviceName</c> set, only that device's subtree is streamed.
/// </summary>
/// <remarks>
/// Belt-and-braces rather than dead code: the production client already narrows the request
/// to <c>{AgentUri}/{DeviceName}/probe</c>, so a conforming Agent answers with one device
/// anyway. But a non-conforming Agent (or a proxy that drops the path segment) that answered
/// agent-wide would otherwise publish every other machine's data items into an instance
/// scoped to one — and the operator's only evidence would be a picker full of ids they never
/// configured.
/// </remarks>
[Fact]
public async Task Discover_with_a_device_scope_streams_only_that_device_subtree()
{
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "Lathe-2Axis"), TwoDeviceModel());
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["Lathe-2Axis"]);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev2_avail"]);
}
/// <summary>
/// A device scope naming a device the Agent does not declare streams nothing. Kept explicit
/// because the alternative reading ("scope did not match, so stream everything") would hand a
/// mis-typed device name a picker showing the whole plant.
/// </summary>
[Fact]
public async Task Discover_with_an_unmatched_device_scope_streams_nothing()
{
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "not-a-device"), TwoDeviceModel());
var cap = await DiscoverAsync(driver);
cap.Folders.ShouldBeEmpty();
cap.Variables.ShouldBeEmpty();
}
/// <summary>
/// A device with no <c>name</c> is addressable by its <c>id</c> — which is what a scoped
/// Agent URL would carry for it too.
/// </summary>
[Fact]
public async Task Discover_device_scope_matches_a_nameless_device_by_id()
{
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(
"dev9", null, [], [new MTConnectDataItem("dev9_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
new MTConnectDevice(
"dev8", "Other", [], [new MTConnectDataItem("dev8_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
]);
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "dev9"), probe);
var cap = await DiscoverAsync(driver);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev9_a"]);
}
// ---- the probe-cache contract (anti-wedge) ----
/// <summary>
/// Discovery is served from the cache <c>InitializeAsync</c> already populated — a browse
/// does not re-hit the Agent for a model it is holding.
/// </summary>
[Fact]
public async Task Discover_serves_from_the_cached_probe_model()
{
var (driver, client) = await InitializedDriverAsync();
client.ProbeCallCount.ShouldBe(1);
await DiscoverAsync(driver);
client.ProbeCallCount.ShouldBe(1);
}
/// <summary>
/// …and after <see cref="MTConnectDriver.FlushOptionalCachesAsync"/> has dropped that cache,
/// discovery re-fetches and still streams the full tree. This is the whole reason discovery
/// must go through <c>GetOrFetchProbeModelAsync</c> rather than reading the cached field: a
/// memory-budget flush must never leave browse permanently answering "this agent has no data
/// items".
/// </summary>
[Fact]
public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
driver.CachedProbeModel.ShouldBeNull();
var cap = await DiscoverAsync(driver);
client.ProbeCallCount.ShouldBe(2);
cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
driver.CachedProbeModel.ShouldNotBeNull();
}
// ---- discovery never touches the subscription surface ----
/// <summary>
/// Discovery is a pure read of the device model: it does not write the shared observation
/// index (the <c>/sample</c> pump is its sole writer) and it does not issue a <c>/current</c>.
/// A discovery that folded a snapshot into the index would be a second writer racing the
/// pump, rolling subscribed values backwards.
/// </summary>
[Fact]
public async Task Discover_does_not_touch_the_observation_index_or_call_current()
{
var options = new MTConnectDriverOptions
{
AgentUri = AgentUri,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
};
var (driver, client) = await InitializedDriverAsync(options);
var index = driver.ObservationIndex;
var indexed = index.Count;
var currentCalls = client.CurrentCallCount;
await DiscoverAsync(driver);
ReferenceEquals(driver.ObservationIndex, index).ShouldBeTrue();
driver.ObservationIndex.Count.ShouldBe(indexed);
client.CurrentCallCount.ShouldBe(currentCalls);
}
// ---- failure posture: loud, never a silently-empty tree ----
/// <summary>
/// Discovery before <c>InitializeAsync</c> throws rather than streaming an empty tree.
/// </summary>
/// <remarks>
/// <b>This is the deliberate opposite of <see cref="MTConnectDriver.ReadAsync"/>'s
/// never-throw posture, and the difference is what the caller can do with the answer.</b> A
/// read has per-reference Bad status codes to report a failure <i>in-band</i>; a discovery
/// stream has no such channel — an empty tree is indistinguishable from "this Agent declares
/// no data items", which is the #485 shape (failure wearing success's clothes) and would read
/// to an operator in the browse picker as a working connection to an empty machine. Both
/// production callers handle the throw: the universal browser surfaces it as a failed browse,
/// and <c>DriverInstanceActor</c> logs it and retries.
/// </remarks>
[Fact]
public async Task Discover_before_initialize_throws_and_streams_nothing()
{
var (driver, client) = NewDriver();
var builder = new CapturingBuilder();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
builder.Folders.ShouldBeEmpty();
builder.Variables.ShouldBeEmpty();
client.ProbeCallCount.ShouldBe(0);
}
/// <summary>Same posture after <c>ShutdownAsync</c> — a stopped driver has no model to browse.</summary>
[Fact]
public async Task Discover_after_shutdown_throws_and_streams_nothing()
{
var (driver, _) = await InitializedDriverAsync();
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
var builder = new CapturingBuilder();
await Should.ThrowAsync<InvalidOperationException>(
() => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
builder.Variables.ShouldBeEmpty();
}
/// <summary>
/// An Agent that fails the <c>/probe</c> a discovery needs fails the discovery — it does not
/// degrade to an empty tree. Same #485 reasoning as the un-initialized case.
/// </summary>
[Fact]
public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
{
var (driver, client) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
client.ProbeFailure = new HttpRequestException("agent down");
var builder = new CapturingBuilder();
await Should.ThrowAsync<HttpRequestException>(
() => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
builder.Variables.ShouldBeEmpty();
}
/// <summary>Caller cancellation propagates, as it does on every other seam of this driver.</summary>
[Fact]
public async Task Discover_propagates_caller_cancellation()
{
var (driver, _) = await InitializedDriverAsync();
await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(
() => driver.DiscoverAsync(new CapturingBuilder(), cts.Token));
}
/// <summary>A null builder is a caller bug — the one thing this method is loud about up front.</summary>
[Fact]
public async Task Discover_with_a_null_builder_throws_ArgumentNullException()
{
var (driver, _) = await InitializedDriverAsync();
await Should.ThrowAsync<ArgumentNullException>(
() => driver.DiscoverAsync(null!, TestContext.Current.CancellationToken));
}
// ---- the browse opt-in ----
/// <summary>
/// The universal browser's <c>CanBrowse</c> constructs a throwaway instance purely to read
/// these two members, so they must answer on an <b>un-initialized</b> driver without
/// dialling anything.
/// </summary>
[Fact]
public void Browse_opt_in_answers_on_an_uninitialized_instance_without_connecting()
{
var (driver, client) = NewDriver();
driver.ShouldBeAssignableTo<ITagDiscovery>();
driver.SupportsOnlineDiscovery.ShouldBeTrue();
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
client.ProbeCallCount.ShouldBe(0);
client.CurrentCallCount.ShouldBe(0);
}
// ---- helpers ----
/// <summary>A one-device model whose single Path component owns <paramref name="dataItems"/>.</summary>
private static MTConnectProbeModel ModelWith(params MTConnectDataItem[] dataItems) =>
new(
[
new MTConnectDevice(
"dev9",
"Probe9",
[new MTConnectComponent("dev9_path", "path", [], dataItems)],
[]),
]);
/// <summary>
/// The canned fixture's device plus a second, independent one — the multi-device Agent the
/// <c>DeviceName</c> scope exists for.
/// </summary>
private static MTConnectProbeModel TwoDeviceModel()
{
var fixture = MTConnectProbeParser.Parse(File.ReadAllText("Fixtures/probe.xml"));
var second = new MTConnectDevice(
"dev2",
"Lathe-2Axis",
[],
[new MTConnectDataItem("dev2_avail", null, "EVENT", "AVAILABILITY", null, null, null, null)]);
return new MTConnectProbeModel([.. fixture.Devices, second]);
}
}