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