dfbcb0e7f0
C1 (critical): a latched "this endpoint cannot stream" verdict could end with a GREEN driver holding a permanently silent subscription. DriverInstanceActor handles a tag-set change as Unsubscribe-then-Subscribe with no re-initialize; the unsubscribe cleared the stream-degraded flag, the resubscribe was refused by the latch without a word, and one successful read then reported Healthy. StartSampleStreamCore now re-asserts the degradation and logs a Warning naming the remedy, and teardown no longer clears the flag while the latch is set. NOT promoted to Faulted: DriverHealthReport maps any Faulted driver to a /readyz 503, so that would de-ready the whole node over one misconfigured Agent whose reads are perfectly healthy. Faulted stays for the case that earns it. I1: teardown waits are bounded (5 s) and honour the caller's token. They were unbounded and ignored it, so DriverInstanceActor's 5 s budget was inert and PostStop — which blocks a dispatcher thread on ShutdownAsync while holding the lifecycle semaphore — could be wedged forever by one blocking subscriber. The cancellation source is disposed only when the loop is provably gone. Applied to StopProbeLoopAsync too: Task 13 reproduced the identical shape, and closing the class in one method while leaving the other as the pattern to copy is worse than not closing it. I2: the reconnect ladder was monotonic for the process lifetime, so ~9 unrelated drops pinned the driver at MaxBackoffMs forever. A stream that delivered before dropping now starts a fresh ladder (at attempt 1, not 0, so MinBackoffMs is still honoured). I3: MaxBackoffMs <= 0 clamped every delay to zero — an operator-authorable reconnect spin. Treated as unset, like a multiplier that cannot grow. I4: the session published instanceId without NextSequence, so after a restart it held the new agent's id beside the dead one's cursor, and a gap/OUT_OF_RANGE re-baseline (id unchanged) never published the cursor at all. Both move in one CAS, and the pump publishes its cursor as it exits. I5: OnDataChange was a multicast Invoke — the first throwing handler aborted the rest of the list, starving every later subscriber. Now walked by hand with the catch inside the loop. The test that claimed to cover this registered the recorder BEFORE the thrower, so it passed regardless; swapped, it was red. Faults are latched to one Warning per stream generation (Debug thereafter) so a consistently-throwing consumer cannot flood the log. Also: the pump restarts after an unexpected fault (IsCompleted, not null); a device-scope that matches nothing is a Warning, not a Debug tally; a device or component with neither name nor id is skipped rather than emitting a blank path segment; DiscoverAsync's remark no longer claims DriverInstanceActor retries (it does not for a Once driver, and injection is dormant in v3); and two flake seeds in the fake are gone (Dispose re-read its own counter; _disposeCts was disposed under a racing SampleAsync). 439/439. Every changed behaviour falsified by mutation.
775 lines
33 KiB
C#
775 lines
33 KiB
C#
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,
|
|
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<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><Device><DataItems></c>, outside every
|
|
/// Component. A walker that only recursed <c><Components></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"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A configured <c>DeviceName</c> that matches nothing is an authoring error whose only
|
|
/// symptom is an empty picker. It must be reported at <b>Warning</b> — at Debug the operator
|
|
/// sees a browse that "worked" and a machine that apparently declares nothing, with no
|
|
/// evidence pointing at their typo.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// …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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A component declaring neither a <c>name</c> nor an <c>id</c> has no browse path at all,
|
|
/// and folding it in would open a folder called <c>""</c> — 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.)
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>The same rule one level up: a device with no browse path is skipped, and said so.</summary>
|
|
[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) ----
|
|
|
|
/// <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, publishes an empty node set, and does NOT retry
|
|
/// (retrying is an <c>UntilStable</c> behaviour; this driver is <c>Once</c>) — which today
|
|
/// reaches nothing anyway, since <c>DriverHostActor</c>'s discovered-node injection is
|
|
/// dormant in v3. The browse path is the consumer this posture is chosen for.
|
|
/// </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]);
|
|
}
|
|
}
|