diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 36214f5a..f3839fd1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -43,10 +43,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// /// Scope. This type currently implements , -/// and . ITagDiscovery (Task 12) -/// and IHostConnectivityProbe/IRediscoverable (Task 13) are added on top of -/// the state this class already caches — the probe model, the Agent instanceId, and -/// the observation index. +/// , and . +/// IHostConnectivityProbe/IRediscoverable (Task 13) are added on top of the +/// state this class already caches — the probe model, the Agent instanceId, and the +/// observation index. There is deliberately no IWritable: MTConnect is a read-only +/// protocol, which is also why every discovered leaf is +/// . /// /// /// The read path never writes the shared observation index — see @@ -54,7 +56,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// (). /// /// -public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable +public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery { /// /// Coarse per-entry byte weights behind . 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 ---- + + /// + /// + /// This one member is the whole browse opt-in. The Wave-0 universal + /// DiscoveryDriverBrowser turns any driver that answers true here into a live + /// address picker by capturing — 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 un-initialized instance without dialling anything, because + /// CanBrowse constructs a throwaway driver purely to read it — which is also why the + /// constructor opens nothing. + /// + public bool SupportsOnlineDiscovery => true; + + /// + /// + /// Once, not the default UntilStable: streams the + /// complete device model within the single call — /probe 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 does change the model is an Agent restart, and that is + /// IRediscoverable's job in Task 13, not a retry cadence's.) + /// + public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; + + /// + /// + /// + /// Streams the Agent's /probe hierarchy verbatim: one folder per Device, + /// one per nested Component to arbitrary depth, and one variable per + /// DataItem under whichever container declares it — including the data items a + /// device declares directly, outside any component (an availability item almost + /// always lives there, and a walker that only recursed Components would silently + /// drop it). + /// + /// + /// is the DataItem@id, never the + /// browse name. The universal browser commits a browsed leaf's FullName + /// straight into TagConfig.FullName, and that is the key + /// and the /sample pump resolve an observation against + /// ( is keyed by DataItem@id). Committing + /// the name instead would produce a picker that looks right and authors tags that + /// can never report a value. The browse name — cosmetic — prefers the DataItem's + /// name and falls back to its id, which for this protocol is the common + /// case rather than an edge case. + /// + /// + /// The type shape comes from 's returned + /// record, in full and + /// included. Recomputing the array shape + /// locally from representation/sampleCount is the tempting shortcut and it + /// is wrong: the inference also demotes DATA_SET/TABLE to + /// even on a SAMPLE, and honours + /// TIME_SERIES only on a SAMPLE. The AdminUI typed editor calls the same + /// function, so any divergence here makes the picker and the editor disagree about one + /// data item. The type attribute is passed verbatim for the same reason: + /// the probe document spells it UPPER_SNAKE (PART_COUNT) where the streams + /// document uses PascalCase, and the inference is separator-insensitive precisely to + /// bridge the two — pre-normalising it here would defeat that. + /// + /// + /// is this seam's job, deliberately not + /// the inference's: a CONDITION data item is an alarm. + /// IVariableHandle.MarkAsAlarmCondition is not 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. + /// + /// + /// The model comes from , never from + /// . 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. + /// + /// + /// This throws, and that is the opposite of 's posture on + /// purpose. 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 DriverInstanceActor logs + /// it and retries on its rediscover tick. + /// + /// + /// Nothing here writes the observation index. Discovery is a pure read of the + /// device model — the /sample pump remains its sole writer. + /// + /// + /// is null. + /// + /// The driver is not initialized, or it was torn down while the /probe fetch was in + /// flight (see ). + /// + 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}')"); + } + + /// + /// Streams one device/component's own data items into , then recurses + /// into each nested component under a folder of its own. Returns how many variables it + /// streamed, for the diagnostic tally. + /// + /// + /// Walks the tree rather than using AllDataItems(): 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. + /// + 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; + } + + /// + /// Whether a device is in this instance's configured scope. An unset scope is agent-wide. + /// + /// + /// + /// Matched against the device's name or its id — 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. + /// + /// + /// Belt-and-braces, not dead code. The production client already narrows the + /// request to {AgentUri}/{DeviceName}/probe, 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 exact for the same reason the URL is: a + /// differently-cased device name is not the device the Agent would have served. + /// + /// + 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); + + /// The folder name for a device/component: its name, or its id when it declares none. + private static string FolderName(string? name, string id) => string.IsNullOrWhiteSpace(name) ? id : name; + + /// + /// Whether a data item's category makes it an alarm. Lives here rather than in + /// because that function answers a type + /// question; this is an address-space one. + /// + private static bool IsCondition(string? category) => + category.AsSpan().Trim().Equals("CONDITION", StringComparison.OrdinalIgnoreCase); + /// /// Starts the shared /sample pump, if there is anything to pump and anything to pump /// from. Caller must hold . diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs new file mode 100644 index 00000000..eed2defd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs @@ -0,0 +1,132 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// An that records the tree a driver streams into it, so +/// DiscoverAsync can be asserted on shape (which node landed under which folder) +/// and not merely on a flat list of leaves. +/// +/// +/// +/// Why this exists rather than a reference to the shipped capturing builder. Two +/// production capturing builders exist — Commons.Browsing.CapturingAddressSpaceBuilder +/// (universal browser) and Runtime.Drivers.CapturingAddressSpaceBuilder (deploy-time +/// discovery) — but this suite deliberately references only the driver and its +/// .Contracts, 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 +/// FullName, which is exactly the conflation a "did the device-level data item land in +/// the device folder?" assertion needs to see through. +/// +/// +/// Deliberately not thread-safe. A driver streams discovery on one caller; recording +/// behind a lock would hide a driver that fanned out onto other threads. +/// +/// +internal sealed class CapturingBuilder : IAddressSpaceBuilder +{ + private readonly State _state; + private readonly string _path; + + /// Creates a root builder — the scope a driver's DiscoverAsync is handed. + public CapturingBuilder() + { + _state = new State(); + _path = string.Empty; + } + + private CapturingBuilder(State state, string path) + { + _state = state; + _path = path; + } + + /// Every folder streamed, in call order, with the slash-joined path it landed at. + public IReadOnlyList Folders => _state.Folders; + + /// Every variable streamed, in call order, with the folder path it landed under. + public IReadOnlyList Variables => _state.Variables; + + /// Every property streamed, with the scope path it was added to. + public IReadOnlyList Properties => _state.Properties; + + /// + 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); + } + + /// + 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); + } + + /// + public void AddProperty(string browseName, DriverDataType dataType, object? value) => + _state.Properties.Add(new CapturedProperty(_path, browseName, dataType, value)); + + private sealed class State + { + public List Folders { get; } = []; + + public List Variables { get; } = []; + + public List 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. + } + } + } +} + +/// One folder captured from a discovery stream. +/// Slash-joined path of the folder itself. +/// Slash-joined path of the scope it was added to (empty at the root). +/// The browse name the driver supplied. +/// The display name the driver supplied. +internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName); + +/// One variable captured from a discovery stream. +/// Slash-joined path of the folder it landed under (empty at the root). +/// The browse name the driver supplied. +/// The display name the driver supplied. +/// The driver-side attribute metadata the driver stamped on it. +internal sealed record CapturedVariable( + string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr) +{ + /// Alarm conditions the driver marked this variable with, if any. + public List AlarmConditions { get; } = []; +} + +/// One property captured from a discovery stream. +/// Slash-joined path of the node it was added to. +/// The property browse name. +/// The property data type. +/// The property value. +internal sealed record CapturedProperty(string ScopePath, string BrowseName, DriverDataType DataType, object? Value); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs new file mode 100644 index 00000000..369e366b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs @@ -0,0 +1,680 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Task 12 — 's half: the +/// /probe device model streamed into an as +/// Device → Component → DataItem, and the one-member browse opt-in +/// () the Wave-0 universal browser keys off. +/// +/// +/// +/// The load-bearing assertion is FullName == DataItem@id. The universal +/// browser commits a browsed leaf's straight into +/// TagConfig.FullName, and that is the key ReadAsync / the /sample pump +/// resolve an observation against ( is keyed by +/// DataItem@id). Streaming the browse name instead would produce a picker that +/// looks perfect and commits tags that can never report a value — every one of them stuck at +/// BadWaitingForInitialData behind a Healthy driver. +/// +/// +/// The second load-bearing assertion is the type shape. Discovery must stamp exactly +/// what 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. PART_COUNT → Int64 is +/// called out on its own because the probe document's UPPER_SNAKE spelling is the case a +/// PascalCase-only match silently types as String. +/// +/// +public sealed class MTConnectDiscoverTests +{ + private const string AgentUri = "http://fixture-agent:5000"; + + /// Every DataItem id the canned Fixtures/probe.xml declares. + 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 DiscoverAsync(MTConnectDriver driver) + { + var builder = new CapturingBuilder(); + await driver.DiscoverAsync(builder, TestContext.Current.CancellationToken); + + return builder; + } + + // ---- the plan's headline test ---- + + /// + /// 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 id, it types as + /// , and the driver declares the browse opt-in the + /// universal browser gates on. + /// + [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 ---- + + /// + /// The folder tree mirrors the probe document exactly: one folder per Device (named by its + /// name), then one per nested Component to arbitrary depth, each under its own parent. + /// + [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"); + } + + /// + /// dev1_avail hangs directly off <Device><DataItems>, outside every + /// Component. A walker that only recursed <Components> would drop it silently, so + /// it is asserted to land in the device folder while the Path-owned items land in the + /// path folder. + /// + [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"); + } + + /// + /// Every DataItem the fixture declares is streamed exactly once, and every leaf's + /// is its DataItem@id — the read/subscribe + /// key, aligned with the browser's commit by construction. + /// + [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); + } + + /// + /// The FullName-is-the-id rule, asserted where the two can actually differ. + /// + /// + /// The fixture cannot prove this on its own — its data items declare no name, so + /// browseName and id coincide and a driver that streamed the browse name as the + /// FullName 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. + /// + [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) ---- + + /// + /// The per-category defaults reach the builder unchanged: SAMPLE ⇒ Float64, controlled + /// vocabulary / free-text EVENT ⇒ String, CONDITION ⇒ String. + /// + [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); + } + + /// + /// PART_COUNT 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 PartCount types every real agent's part counter as + /// while a PascalCase unit test stays green. Asserted at + /// the discovery seam because that is where the probe spelling is actually read. + /// + [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); + } + + /// + /// A TIME_SERIES SAMPLE is an array whose length is the probe-declared + /// sampleCount. The shape comes from 's + /// returned record — recomputing it inline at this seam is what makes discovery and the + /// AdminUI editor disagree. + /// + [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); + } + + /// + /// A DATA_SET SAMPLE demotes to and stays scalar — + /// a rule that lives only 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. + /// + [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(); + } + + /// + /// TIME_SERIES 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". + /// + [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 ---- + + /// + /// is the caller's job — the inference deliberately + /// does not compute it. Exactly the CONDITION data item carries it. + /// + [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"]); + } + + /// + /// MTConnect is a read-only protocol and this driver implements no IWritable, so every + /// browsed leaf is — 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. + /// + [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 ---- + + /// + /// The browse name is the DataItem's name when it declares one and its id + /// otherwise — the fixture's data items declare none, so the fallback is the normal case for + /// this protocol, not an edge case. + /// + [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"); + } + + /// + /// A Device or Component with no name attribute falls back to its id rather + /// than materialising a blank folder. + /// + [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 ---- + + /// + /// With no DeviceName 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. + /// + [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"); + } + + /// + /// With DeviceName set, only that device's subtree is streamed. + /// + /// + /// Belt-and-braces rather than dead code: the production client already narrows the request + /// to {AgentUri}/{DeviceName}/probe, 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. + /// + [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"]); + } + + /// + /// 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. + /// + [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(); + } + + /// + /// A device with no name is addressable by its id — which is what a scoped + /// Agent URL would carry for it too. + /// + [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) ---- + + /// + /// Discovery is served from the cache InitializeAsync already populated — a browse + /// does not re-hit the Agent for a model it is holding. + /// + [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); + } + + /// + /// …and after has dropped that cache, + /// discovery re-fetches and still streams the full tree. This is the whole reason discovery + /// must go through GetOrFetchProbeModelAsync rather than reading the cached field: a + /// memory-budget flush must never leave browse permanently answering "this agent has no data + /// items". + /// + [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 ---- + + /// + /// Discovery is a pure read of the device model: it does not write the shared observation + /// index (the /sample pump is its sole writer) and it does not issue a /current. + /// A discovery that folded a snapshot into the index would be a second writer racing the + /// pump, rolling subscribed values backwards. + /// + [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 ---- + + /// + /// Discovery before InitializeAsync throws rather than streaming an empty tree. + /// + /// + /// This is the deliberate opposite of 's + /// never-throw posture, and the difference is what the caller can do with the answer. A + /// read has per-reference Bad status codes to report a failure in-band; 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 DriverInstanceActor logs it and retries. + /// + [Fact] + public async Task Discover_before_initialize_throws_and_streams_nothing() + { + var (driver, client) = NewDriver(); + var builder = new CapturingBuilder(); + + await Should.ThrowAsync( + () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken)); + + builder.Folders.ShouldBeEmpty(); + builder.Variables.ShouldBeEmpty(); + client.ProbeCallCount.ShouldBe(0); + } + + /// Same posture after ShutdownAsync — a stopped driver has no model to browse. + [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( + () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken)); + + builder.Variables.ShouldBeEmpty(); + } + + /// + /// An Agent that fails the /probe a discovery needs fails the discovery — it does not + /// degrade to an empty tree. Same #485 reasoning as the un-initialized case. + /// + [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( + () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken)); + + builder.Variables.ShouldBeEmpty(); + } + + /// Caller cancellation propagates, as it does on every other seam of this driver. + [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( + () => driver.DiscoverAsync(new CapturingBuilder(), cts.Token)); + } + + /// A null builder is a caller bug — the one thing this method is loud about up front. + [Fact] + public async Task Discover_with_a_null_builder_throws_ArgumentNullException() + { + var (driver, _) = await InitializedDriverAsync(); + + await Should.ThrowAsync( + () => driver.DiscoverAsync(null!, TestContext.Current.CancellationToken)); + } + + // ---- the browse opt-in ---- + + /// + /// The universal browser's CanBrowse constructs a throwaway instance purely to read + /// these two members, so they must answer on an un-initialized driver without + /// dialling anything. + /// + [Fact] + public void Browse_opt_in_answers_on_an_uninitialized_instance_without_connecting() + { + var (driver, client) = NewDriver(); + + driver.ShouldBeAssignableTo(); + driver.SupportsOnlineDiscovery.ShouldBeTrue(); + driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once); + client.ProbeCallCount.ShouldBe(0); + client.CurrentCallCount.ShouldBe(0); + } + + // ---- helpers ---- + + /// A one-device model whose single Path component owns . + private static MTConnectProbeModel ModelWith(params MTConnectDataItem[] dataItems) => + new( + [ + new MTConnectDevice( + "dev9", + "Probe9", + [new MTConnectComponent("dev9_path", "path", [], dataItems)], + []), + ]); + + /// + /// The canned fixture's device plus a second, independent one — the multi-device Agent the + /// DeviceName scope exists for. + /// + 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]); + } +}