From 1bfc1722b35b911b0c567d7d338a8593a4b85ada Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 15:38:55 -0400 Subject: [PATCH] feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10) One deadline-bounded /current per batch, answered out of that one whole-device document: N references cost one round-trip, one snapshot each, in request order, duplicates included. DEVIATION (deliberate, from the plan's implied shared-index write): the response is indexed into a PER-CALL snapshot index, never into the driver's shared observation index. That index is written by Task 11's /sample pump and is last-write-wins with no timestamp arbitration; a read folding its /current into it would be a second writer racing the first, and /current is frequently the OLDER document (the Agent composes it while the pump's newer delta is in flight). Losing that race rolls a subscribed value backwards and leaves it there until the data item next changes. Both paths coerce through the same MTConnectObservationIndex logic, so a read and a subscription report a given observation identically -- the read path is simply a pure function of (document, authored tags). Failure posture is the inverse of InitializeAsync: nothing but genuine caller cancellation crosses the capability boundary. Unreachable Agent / blown deadline / unusable answer => every ref codes BadCommunicationError and the driver degrades (never downgrading Faulted); no client at all (pre-initialize, faulted, post-shutdown) => BadNotConnected with health untouched. The index's BadWaitingForInitialData / BadNodeIdUnknown / BadNoCommunication / BadNotSupported distinctions pass through unmodified. 21 tests pinning arity, order, duplicates, empty batch, one-round-trip, each Bad code, read-time freshness, the anti-clobber invariant, the deadline, and cancellation-propagates-but-agent-failure-does-not. CannedAgentClient gains a cancellation-observing CurrentGate (still no timers or sleeps of its own). Test csproj takes the AbCip/S7 OTOPCUA0001 NoWarn -- this suite calls the capability directly by design. 329/329 green. --- .../MTConnectDriver.cs | 186 ++++++- .../CannedAgentClient.cs | 22 +- .../MTConnectReadTests.cs | 456 ++++++++++++++++++ ...M.WW.OtOpcUa.Driver.MTConnect.Tests.csproj | 7 + 4 files changed, 658 insertions(+), 13 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs 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 abe20dd0..71d11215 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -42,14 +42,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// nextSequence. /// /// -/// Scope. This type currently implements only. -/// IReadable (Task 10), ISubscribable (Task 11), 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. +/// Scope. This type currently implements and +/// . ISubscribable (Task 11), 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. +/// +/// +/// The read path never writes the shared observation index — see +/// . That index has exactly one writer, and Task 11's /sample +/// pump is it. /// /// -public sealed class MTConnectDriver : IDriver +public sealed class MTConnectDriver : IDriver, IReadable { /// /// Coarse per-entry byte weights behind . The contract asks @@ -66,6 +71,20 @@ public sealed class MTConnectDriver : IDriver /// Estimated retained bytes per authored . private const int TagBytesPerEntry = 256; + /// + /// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct + /// from the index's BadNoCommunication, which means the Agent answered and said the + /// machine has no value — the two failures need different operator responses. + /// + private const uint BadCommunicationError = 0x80050000u; + + /// + /// There is no Agent client at all: the driver has not been initialized, its initialize + /// faulted, or it has been shut down. Deliberately not one of the index's codes — none of + /// them would say "this driver is not running", which is the only true statement available. + /// + private const uint BadNotConnected = 0x808A0000u; + /// /// Config-JSON reader options, mirroring the sibling driver factories. Note there is /// deliberately no JsonStringEnumConverter: enum-carrying DTO fields stay @@ -137,10 +156,16 @@ public sealed class MTConnectDriver : IDriver public string DriverType => "MTConnect"; /// - /// The live dataItemId → snapshot map. Consumed by IReadable (Task 10) and the - /// /sample pump (Task 11); exposed to tests as the observable proof that - /// actually primed it. + /// The live dataItemId → snapshot map that backs the subscription surface: + /// primed by and thereafter written only by the /sample + /// pump (Task 11). Exposed to tests as the observable proof that initialize actually primed + /// it — and, in MTConnectReadTests, that leaves it alone. /// + /// + /// Read does not consume this. indexes its own /current + /// into a per-call snapshot instead, so the pump keeps a single writer — see the failure + /// analysis in 's remarks. + /// internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index); /// @@ -337,6 +362,110 @@ public sealed class MTConnectDriver : IDriver return Task.CompletedTask; } + /// + /// + /// + /// One /current per batch, whatever the batch size. MTConnect's + /// /current answers for the whole device (or the whole Agent), so N references + /// cost one deadline-bounded round-trip and are answered out of that one document. There + /// is no per-reference request to make and none is made. + /// + /// + /// The response is indexed into a per-call snapshot, NOT into the driver's shared + /// observation index. That index is written by the /sample pump (Task 11) and + /// is deliberately last-write-wins with no timestamp arbitration. A read that folded its + /// /current into it would be a second writer racing the first, and the + /// /current document is frequently the older of the two — the Agent + /// composes it while the pump's newer delta is already in flight. Losing that race rolls + /// a subscribed value backwards and leaves it there until the data item next + /// changes, which for an EVENT such as Execution can be hours. Keeping the + /// read path a pure function of (document, authored tags) costs one index construction + /// per batch — negligible beside the HTTP round-trip that precedes it — and makes it + /// impossible for a read to corrupt subscription state. The reference source-of-truth is + /// unchanged either way: both paths coerce through the same + /// logic, so a read and a subscription report a + /// given observation identically. + /// + /// + /// Nothing but genuine caller cancellation crosses this boundary. An unreachable + /// Agent, a blown deadline, an unusable answer — each fails the batch's values + /// (every reference codes ) rather than the batch: an + /// exception here would also fail the references that were perfectly readable, and the + /// node-manager cannot tell a thrown read from a broken driver. This is the opposite + /// posture to , which is specified to throw so the + /// runtime's retry/backoff loop can own recovery. + /// + /// + /// The status-code distinctions the index draws — BadWaitingForInitialData for an + /// authored-but-unreported id, BadNodeIdUnknown for an unknown one, + /// BadNoCommunication for the Agent's UNAVAILABLE — are passed through + /// unmodified. They tell an operator three different things. + /// + /// + /// + /// is null — a caller bug, not device data, and the + /// one thing this method is loud about (matching 's + /// documented posture). + /// + public async Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fullReferences); + + // Checked before anything else: an empty batch must cost the Agent nothing, and there is no + // failure it could report. + if (fullReferences.Count == 0) + { + return []; + } + + var client = _client; + var options = _options; + + if (client is null) + { + // Before InitializeAsync, after a faulted one, or after ShutdownAsync. Health is left + // exactly as it is: a read attempted against a driver that was never started (or was + // deliberately stopped) is not evidence of degradation. + return BadBatch(fullReferences.Count, BadNotConnected); + } + + try + { + var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, cancellationToken) + .ConfigureAwait(false); + + // Built from the SAME authored tags as the shared index, so the authored-vs-unknown + // distinction and every coercion behave identically — it is a snapshot, not a variant. + var snapshot = new MTConnectObservationIndex(options.Tags); + snapshot.Apply(current); + + var results = new DataValueSnapshot[fullReferences.Count]; + for (var i = 0; i < results.Length; i++) + { + // Get never throws and never returns null, including for a null/blank reference. + results[i] = snapshot.Get(fullReferences[i]); + } + + WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + + return results; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The caller asked us to stop. This is the ONLY exception that may propagate: a blown + // per-call deadline arrives as the TimeoutException BoundedAsync raises instead, and is + // handled below as the Agent failure it is. + throw; + } + catch (Exception ex) + { + DegradeAfterReadFailure(ex); + + return BadBatch(fullReferences.Count, BadCommunicationError); + } + } + /// /// The /probe device model: the cached copy when warm, otherwise a fresh /// deadline-bounded /probe that re-populates the cache. This is the accessor every @@ -609,6 +738,45 @@ public sealed class MTConnectDriver : IDriver } } + /// + /// One Bad-coded snapshot per requested reference — the shape every read failure degrades + /// to. Arity is preserved because the caller indexes the result positionally against the + /// references it asked for. + /// + private static DataValueSnapshot[] BadBatch(int count, uint status) + { + // No source timestamp: nothing was observed. The server timestamp is when we gave up. + var serverTimestamp = DateTime.UtcNow; + var results = new DataValueSnapshot[count]; + + for (var i = 0; i < count; i++) + { + results[i] = new DataValueSnapshot(null, status, null, serverTimestamp); + } + + return results; + } + + /// + /// Routes a read failure to the health surface (05/STAB-9's fleet-wide shape): log it and + /// degrade, preserving LastSuccessfulRead — the "when did this driver last hear from + /// the Agent" diagnostic. Never downgrades , which is a + /// stronger, config-level signal that a transient read failure must not paper over. + /// + private void DegradeAfterReadFailure(Exception ex) + { + var current = ReadHealth(); + if (current.State != DriverState.Faulted) + { + WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message)); + } + + _logger.LogWarning( + ex, + "MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.", + _driverInstanceId, _options.AgentUri); + } + private static void SafeDispose(IMTConnectAgentClient? client) { if (client is null) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs index 85acca43..9953754b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -81,6 +81,14 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable /// When set, /current throws this instead of answering. public Exception? CurrentFailure { get; set; } + /// + /// When set, /current does not answer until this source completes — an Agent that + /// accepted the request and then went quiet. The wait is cancellation-observing, so the + /// caller's own deadline is what ends it; the test never sleeps and never races a timer it + /// did not set. Leave null for the ordinary immediate answer. + /// + public TaskCompletionSource? CurrentGate { get; set; } + /// Number of /probe requests issued against this client. public int ProbeCallCount => Volatile.Read(ref _probeCallCount); @@ -110,15 +118,21 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable } /// - public Task CurrentAsync(CancellationToken ct) + public async Task CurrentAsync(CancellationToken ct) { ObjectDisposedException.ThrowIf(IsDisposed, this); ct.ThrowIfCancellationRequested(); Interlocked.Increment(ref _currentCallCount); - return CurrentFailure is null - ? Task.FromResult(Current) - : Task.FromException(CurrentFailure); + // The request was accepted; the answer is withheld until the test releases the gate or the + // caller's deadline cancels the token. + var gate = CurrentGate; + if (gate is not null) + { + await gate.Task.WaitAsync(ct).ConfigureAwait(false); + } + + return CurrentFailure is null ? Current : throw CurrentFailure; } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs new file mode 100644 index 00000000..ad9288dd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs @@ -0,0 +1,456 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Task 10 — 's half: one /current +/// per batch, one snapshot per requested reference in request order, and a Bad code for every +/// shape of failure. Every test runs against ; no socket is +/// opened anywhere in this file. +/// +/// +/// +/// The load-bearing assertions are about arity, order, and not-throwing. The caller +/// (the runtime's dispatch layer) indexes the returned list positionally against the +/// references it asked for, so a length mismatch or a reorder is silent data corruption — +/// every value lands on the wrong tag under Good quality. And every failure this driver can +/// meet at read time (agent down, deadline blown, unparseable answer, unknown id, driver not +/// initialized) must arrive as a Bad-coded snapshot: an exception out of +/// fails the whole batch including the references that +/// were perfectly readable. +/// +/// +/// The one exception that may propagate is genuine caller cancellation, and it is +/// pinned here alongside its opposite (an agent failure under an uncancelled token) — the +/// two are one catch filter apart in the implementation, and getting the filter +/// backwards turns every dead agent into a thrown batch. +/// +/// +public sealed class MTConnectReadTests +{ + private const string AgentUri = "http://fixture-agent:5000"; + + // Canonical Opc.Ua.StatusCodes numerics, restated here so the test asserts the wire value an + // OPC UA client actually sees rather than a driver-private constant it could drift with. + private const uint Good = 0x00000000u; + private const uint BadMask = 0x80000000u; + private const uint BadCommunicationError = 0x80050000u; + private const uint BadNoCommunication = 0x80310000u; + private const uint BadWaitingForInitialData = 0x80320000u; + private const uint BadNodeIdUnknown = 0x80340000u; + private const uint BadNotSupported = 0x803D0000u; + private const uint BadNotConnected = 0x808A0000u; + + /// + /// Authored tags spanning every read outcome the fixtures can produce: a coercible Good + /// value, an UNAVAILABLE one, a TIME_SERIES vector, and one authored id no fixture + /// ever reports. + /// + private static MTConnectDriverOptions Opts(int requestTimeoutMs = 5000) => + new() + { + AgentUri = AgentUri, + RequestTimeoutMs = requestTimeoutMs, + Tags = + [ + new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), + new MTConnectTagDefinition("dev1_execution", DriverDataType.String), + new MTConnectTagDefinition("dev1_partcount", DriverDataType.Int32), + new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10), + new MTConnectTagDefinition("dev1_never_reported", DriverDataType.Int32), + ], + }; + + private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver( + MTConnectDriverOptions? options = null) + { + var client = CannedAgentClient.FromFixtures(); + + return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client); + } + + private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync( + MTConnectDriverOptions? options = null) + { + var (driver, client) = NewDriver(options); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + return (driver, client); + } + + // ---- arity + order: the contract the caller indexes positionally ---- + + /// The plan's TDD case: one snapshot per ref, in order, absent ⇒ Bad rather than a throw. + [Fact] + public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_pos", "does-not-exist"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(2); + res[0].StatusCode.ShouldBe(Good); + (res[1].StatusCode & BadMask).ShouldBe(BadMask); + } + + /// + /// A mixed batch requested in an order that matches neither the document order nor any + /// dictionary enumeration order. This is the assertion that catches a driver that answers + /// with the index's own key order instead of the request's. + /// + [Fact] + public async Task Read_answers_in_request_order_not_document_order() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync( + ["dev1_program", "dev1_pos", "does-not-exist", "dev1_partcount", "dev1_execution"], + TestContext.Current.CancellationToken); + + res.Count.ShouldBe(5); + + // dev1_program is observed but NOT authored — String is the only coercion that cannot fail. + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBe("O1234"); + + res[1].StatusCode.ShouldBe(Good); + res[1].Value.ShouldBe(123.4567d); + + res[2].StatusCode.ShouldBe(BadNodeIdUnknown); + + res[3].StatusCode.ShouldBe(BadNoCommunication); + + res[4].StatusCode.ShouldBe(Good); + res[4].Value.ShouldBe("ACTIVE"); + } + + /// + /// A reference repeated in one request gets a snapshot per occurrence. De-duplicating would + /// shorten the list and shift every later reference onto the wrong tag. + /// + [Fact] + public async Task Read_returns_a_snapshot_per_duplicate_occurrence() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync( + ["dev1_pos", "dev1_execution", "dev1_pos"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(3); + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBe(123.4567d); + res[1].Value.ShouldBe("ACTIVE"); + res[2].StatusCode.ShouldBe(Good); + res[2].Value.ShouldBe(123.4567d); + } + + /// An empty batch is empty — and, being empty, costs the Agent nothing. + [Fact] + public async Task Read_of_an_empty_batch_is_empty_and_costs_no_round_trip() + { + var (driver, client) = await InitializedDriverAsync(); + var before = client.CurrentCallCount; + + var res = await driver.ReadAsync([], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(0); + client.CurrentCallCount.ShouldBe(before); + } + + /// + /// A batch of N references costs exactly ONE /current. The whole point of reading the + /// Agent's whole-device snapshot is that per-tag round-trips never happen. + /// + [Fact] + public async Task Read_of_many_refs_costs_exactly_one_current_call() + { + var (driver, client) = await InitializedDriverAsync(); + var before = client.CurrentCallCount; + + var res = await driver.ReadAsync( + ["dev1_pos", "dev1_execution", "dev1_partcount", "dev1_program", "does-not-exist", "dev1_pos"], + TestContext.Current.CancellationToken); + + res.Count.ShouldBe(6); + client.CurrentCallCount.ShouldBe(before + 1); + } + + // ---- status-code fidelity: the index's distinctions must survive the trip ---- + + /// A genuinely unknown id — neither authored nor ever observed. + [Fact] + public async Task Read_of_an_unknown_ref_reports_BadNodeIdUnknown() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["nope"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNodeIdUnknown); + res[0].Value.ShouldBeNull(); + } + + /// + /// An authored id the Agent has never reported is BadWaitingForInitialData, NOT + /// BadNodeIdUnknown — collapsing the two would tell an operator their correctly + /// authored tag does not exist. + /// + [Fact] + public async Task Read_of_an_authored_but_unreported_ref_reports_BadWaitingForInitialData() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_never_reported"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadWaitingForInitialData); + } + + /// The Agent said UNAVAILABLE: reachable Agent, absent machine value. + [Fact] + public async Task Read_of_an_unavailable_ref_reports_BadNoCommunication() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_partcount"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNoCommunication); + res[0].Value.ShouldBeNull(); + res[0].SourceTimestampUtc.ShouldNotBeNull(); + } + + /// A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral). + [Fact] + public async Task Read_of_a_time_series_ref_reports_BadNotSupported() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNotSupported); + } + + /// A blank reference is data, not a programming error — it codes Bad, it does not throw. + [Fact] + public async Task Read_of_a_blank_ref_is_bad_not_a_throw() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["", " ", "dev1_pos"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(3); + res[0].StatusCode.ShouldBe(BadNodeIdUnknown); + res[1].StatusCode.ShouldBe(BadNodeIdUnknown); + res[2].StatusCode.ShouldBe(Good); + } + + /// The authored type is honoured: the Agent's text becomes a CLR value of that type. + [Fact] + public async Task Read_coerces_the_agent_text_to_the_authored_type() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBeOfType().ShouldBe(123.4567d); + res[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc)); + } + + // ---- freshness: a read is a read, not a replay of the initialize baseline ---- + + /// + /// The whole justification for spending a round-trip: an OPC UA Read must reflect the + /// device's current state, not whatever + /// happened to prime. A driver that served the primed index would pass every other test in + /// this file. + /// + [Fact] + public async Task Read_reflects_the_agent_state_at_read_time_not_the_initialize_baseline() + { + var (driver, client) = await InitializedDriverAsync(); + + // The Agent has moved on since the priming /current (sample.xml reports 124.0100). + client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml"); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBe(124.01d); + } + + /// + /// The anti-clobber pin. ReadAsync indexes its /current into a + /// per-call snapshot and never writes the driver's shared observation index — that index has + /// exactly one writer, the Task 11 /sample pump. If a read wrote it, a /current + /// document older than the pump's newest delta would silently roll a subscribed value + /// backwards and leave it there until the next change. + /// + [Fact] + public async Task Read_does_not_write_the_shared_observation_index() + { + var (driver, client) = await InitializedDriverAsync(); + var primed = driver.ObservationIndex.Get("dev1_pos"); + + client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml"); + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + // The read saw the new value... + res[0].Value.ShouldBe(124.01d); + + // ...and the shared index still holds exactly what the pump/priming left there. + var after = driver.ObservationIndex.Get("dev1_pos"); + after.Value.ShouldBe(primed.Value); + after.Value.ShouldBe(123.4567d); + } + + // ---- failure posture: Bad snapshots, never exceptions ---- + + /// + /// An unreachable Agent fails the batch's values, not the batch. Every reference codes + /// BadCommunicationError and the call returns normally. + /// + [Fact] + public async Task Read_codes_every_ref_bad_when_the_agent_call_fails() + { + var (driver, client) = await InitializedDriverAsync(); + client.CurrentFailure = new HttpRequestException("connection refused"); + + var res = await driver.ReadAsync( + ["dev1_pos", "dev1_execution", "does-not-exist"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(3); + res.ShouldAllBe(r => r.StatusCode == BadCommunicationError); + res.ShouldAllBe(r => r.Value == null); + } + + /// An answer the driver cannot make sense of is a Bad batch, not a thrown one. + [Fact] + public async Task Read_codes_every_ref_bad_when_the_agent_answer_is_unusable() + { + var (driver, client) = await InitializedDriverAsync(); + client.CurrentFailure = new System.Xml.XmlException("unexpected end of document"); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadCommunicationError); + } + + /// + /// A failed read degrades the driver rather than leaving it advertising Healthy, and a later + /// successful read restores it. LastSuccessfulRead survives the degradation — it is + /// the "when did this driver last hear from the Agent" diagnostic. + /// + [Fact] + public async Task Read_failure_degrades_the_driver_and_a_later_success_restores_it() + { + var (driver, client) = await InitializedDriverAsync(); + client.CurrentFailure = new HttpRequestException("connection refused"); + + await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + var degraded = driver.GetHealth(); + degraded.State.ShouldBe(DriverState.Degraded); + degraded.LastSuccessfulRead.ShouldNotBeNull(); + degraded.LastError.ShouldNotBeNullOrWhiteSpace(); + + client.CurrentFailure = null; + await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// R2-01 — the /current runs under a bounded deadline. An Agent that accepts the + /// request and never answers surfaces as a Bad batch on the driver's own clock rather than + /// wedging the caller forever. + /// + [Fact] + public async Task Read_is_bounded_by_the_per_call_deadline() + { + var (driver, client) = await InitializedDriverAsync(Opts(requestTimeoutMs: 50)); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.CurrentGate = gate; + + try + { + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(1); + res[0].StatusCode.ShouldBe(BadCommunicationError); + } + finally + { + gate.TrySetResult(); + } + } + + /// + /// The one exception that may cross the capability boundary. Note the contrast with + /// : same code path, one + /// catch filter apart. + /// + [Fact] + public async Task Read_propagates_genuine_caller_cancellation() + { + var (driver, _) = await InitializedDriverAsync(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync( + () => driver.ReadAsync(["dev1_pos"], cts.Token)); + } + + // ---- reads outside the initialized window ---- + + /// + /// A read before InitializeAsync has no Agent to ask. It reports + /// BadNotConnected for every reference — it does not throw, it does not + /// NullReferenceException, and it does not invent a value. + /// + [Fact] + public async Task Read_before_initialize_is_bad_not_connected() + { + var (driver, client) = NewDriver(); + + var res = await driver.ReadAsync(["dev1_pos", "nope"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(2); + res.ShouldAllBe(r => r.StatusCode == BadNotConnected); + client.CurrentCallCount.ShouldBe(0); + + // An un-started driver is Unknown, not Degraded: nothing has failed yet. + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + /// + /// Same posture after ShutdownAsync, and shutting down must stay reported as + /// — a read attempted against a deliberately stopped + /// driver is not a degradation. + /// + [Fact] + public async Task Read_after_shutdown_is_bad_not_connected_and_leaves_health_alone() + { + var (driver, _) = await InitializedDriverAsync(); + await driver.ShutdownAsync(TestContext.Current.CancellationToken); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNotConnected); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + /// + /// A null reference list is a caller bug, not device data — the one place this method + /// is allowed to be loud, matching 's documented + /// posture ("the only exceptions raised are ArgumentNullException for a null argument"). + /// + [Fact] + public async Task Read_of_a_null_ref_list_throws_ArgumentNullException() + { + var (driver, _) = await InitializedDriverAsync(); + + await Should.ThrowAsync( + () => driver.ReadAsync(null!, TestContext.Current.CancellationToken)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj index 02432590..866f7a9e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj @@ -1,5 +1,12 @@ + + + $(NoWarn);OTOPCUA0001 + + net10.0 enable