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,
RecordingDriverLogger? logger = null)
{
var client = CannedAgentClient.FromFixtures();
if (probe is not null)
{
client.Probe = probe;
}
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client, logger), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
MTConnectDriverOptions? options = null,
MTConnectProbeModel? probe = null,
RecordingDriverLogger? logger = null)
{
var (driver, client) = NewDriver(options, probe, logger);
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"]);
}
///
/// A configured DeviceName that matches nothing is an authoring error whose only
/// symptom is an empty picker. It must be reported at Warning — at Debug the operator
/// sees a browse that "worked" and a machine that apparently declares nothing, with no
/// evidence pointing at their typo.
///
[Fact]
public async Task Discover_warns_when_the_configured_device_scope_matches_nothing()
{
var logger = new RecordingDriverLogger();
var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "no-such-device"), logger: logger);
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldBeEmpty();
logger.WarningsSnapshot()
.ShouldContain(w => w.Contains("no-such-device", StringComparison.Ordinal));
}
///
/// …and an agent-wide browse that finds nothing is NOT that warning: it is a different
/// statement (the Agent declares no devices) and must not cry scope-typo.
///
[Fact]
public async Task Discover_does_not_warn_about_scope_when_no_scope_is_configured()
{
var logger = new RecordingDriverLogger();
var (driver, _) = await InitializedDriverAsync(probe: new MTConnectProbeModel([]), logger: logger);
var cap = await DiscoverAsync(driver);
cap.Variables.ShouldBeEmpty();
logger.WarningsSnapshot().ShouldBeEmpty();
}
///
/// A component declaring neither a name nor an id has no browse path at all,
/// and folding it in would open a folder called "" — a blank segment in the path of
/// everything beneath it. It is skipped; its siblings are unaffected. (A component with a
/// blank id but a real name is fine — the id is only the fallback.)
///
[Fact]
public async Task Discover_skips_a_component_with_neither_a_name_nor_an_id()
{
var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(
"dev9",
"VMC",
[
new MTConnectComponent(" ", " ", [], [item]),
new MTConnectComponent(" ", "Axes", [], [item]),
new MTConnectComponent("dev9_ctrl", null, [], [item]),
],
[]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["VMC", "VMC/Axes", "VMC/dev9_ctrl"], ignoreOrder: true);
cap.Folders.ShouldAllBe(f => !f.Path.Contains("//", StringComparison.Ordinal));
}
/// The same rule one level up: a device with no browse path is skipped, and said so.
[Fact]
public async Task Discover_skips_a_device_with_neither_a_name_nor_an_id()
{
var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
var logger = new RecordingDriverLogger();
var probe = new MTConnectProbeModel(
[
new MTConnectDevice(" ", " ", [], [item]),
new MTConnectDevice("dev9", "VMC", [], [item]),
]);
var (driver, _) = await InitializedDriverAsync(probe: probe, logger: logger);
var cap = await DiscoverAsync(driver);
cap.Folders.Select(f => f.Path).ShouldBe(["VMC"]);
logger.WarningsSnapshot()
.ShouldContain(w => w.Contains("neither a name nor an id", StringComparison.Ordinal));
}
// ---- 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, publishes an empty node set, and does NOT retry
/// (retrying is an UntilStable behaviour; this driver is Once) — which today
/// reaches nothing anyway, since DriverHostActor's discovered-node injection is
/// dormant in v3. The browse path is the consumer this posture is chosen for.
///
[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]);
}
}