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.
This commit is contained in:
@@ -42,14 +42,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|||||||
/// <c>nextSequence</c>.
|
/// <c>nextSequence</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/> only.
|
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/> and
|
||||||
/// <c>IReadable</c> (Task 10), <c>ISubscribable</c> (Task 11), <c>ITagDiscovery</c>
|
/// <see cref="IReadable"/>. <c>ISubscribable</c> (Task 11), <c>ITagDiscovery</c> (Task 12)
|
||||||
/// (Task 12) and <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on
|
/// and <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on top of
|
||||||
/// top of the state this class already caches — the probe model, the Agent
|
/// the state this class already caches — the probe model, the Agent <c>instanceId</c>, and
|
||||||
/// <c>instanceId</c>, and the observation index.
|
/// the observation index.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The read path never writes the shared observation index</b> — see
|
||||||
|
/// <see cref="ReadAsync"/>. That index has exactly one writer, and Task 11's <c>/sample</c>
|
||||||
|
/// pump is it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class MTConnectDriver : IDriver
|
public sealed class MTConnectDriver : IDriver, IReadable
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
|
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
|
||||||
@@ -66,6 +71,20 @@ public sealed class MTConnectDriver : IDriver
|
|||||||
/// <summary>Estimated retained bytes per authored <see cref="MTConnectTagDefinition"/>.</summary>
|
/// <summary>Estimated retained bytes per authored <see cref="MTConnectTagDefinition"/>.</summary>
|
||||||
private const int TagBytesPerEntry = 256;
|
private const int TagBytesPerEntry = 256;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
|
||||||
|
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
|
||||||
|
/// machine has no value — the two failures need different operator responses.
|
||||||
|
/// </summary>
|
||||||
|
private const uint BadCommunicationError = 0x80050000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private const uint BadNotConnected = 0x808A0000u;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Config-JSON reader options, mirroring the sibling driver factories. Note there is
|
/// Config-JSON reader options, mirroring the sibling driver factories. Note there is
|
||||||
/// deliberately <b>no</b> <c>JsonStringEnumConverter</c>: enum-carrying DTO fields stay
|
/// deliberately <b>no</b> <c>JsonStringEnumConverter</c>: enum-carrying DTO fields stay
|
||||||
@@ -137,10 +156,16 @@ public sealed class MTConnectDriver : IDriver
|
|||||||
public string DriverType => "MTConnect";
|
public string DriverType => "MTConnect";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The live <c>dataItemId → snapshot</c> map. Consumed by <c>IReadable</c> (Task 10) and the
|
/// The live <c>dataItemId → snapshot</c> map that backs the <b>subscription</b> surface:
|
||||||
/// <c>/sample</c> pump (Task 11); exposed to tests as the observable proof that
|
/// primed by <see cref="InitializeAsync"/> and thereafter written only by the <c>/sample</c>
|
||||||
/// <see cref="InitializeAsync"/> actually primed it.
|
/// pump (Task 11). Exposed to tests as the observable proof that initialize actually primed
|
||||||
|
/// it — and, in <c>MTConnectReadTests</c>, that <see cref="ReadAsync"/> leaves it alone.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Read does not consume this.</b> <see cref="ReadAsync"/> indexes its own <c>/current</c>
|
||||||
|
/// into a per-call snapshot instead, so the pump keeps a single writer — see the failure
|
||||||
|
/// analysis in <see cref="ReadAsync"/>'s remarks.
|
||||||
|
/// </remarks>
|
||||||
internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index);
|
internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -337,6 +362,110 @@ public sealed class MTConnectDriver : IDriver
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One <c>/current</c> per batch, whatever the batch size.</b> MTConnect's
|
||||||
|
/// <c>/current</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The response is indexed into a per-call snapshot, NOT into the driver's shared
|
||||||
|
/// observation index.</b> That index is written by the <c>/sample</c> pump (Task 11) and
|
||||||
|
/// is deliberately last-write-wins with no timestamp arbitration. A read that folded its
|
||||||
|
/// <c>/current</c> into it would be a second writer racing the first, and the
|
||||||
|
/// <c>/current</c> document is frequently the <i>older</i> 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 <b>leaves it there until the data item next
|
||||||
|
/// changes</b>, which for an EVENT such as <c>Execution</c> 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
|
||||||
|
/// <see cref="MTConnectObservationIndex"/> logic, so a read and a subscription report a
|
||||||
|
/// given observation identically.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing but genuine caller cancellation crosses this boundary.</b> An unreachable
|
||||||
|
/// Agent, a blown deadline, an unusable answer — each fails the batch's <i>values</i>
|
||||||
|
/// (every reference codes <see cref="BadCommunicationError"/>) 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 <see cref="InitializeAsync"/>, which is specified to throw so the
|
||||||
|
/// runtime's retry/backoff loop can own recovery.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The status-code distinctions the index draws — <c>BadWaitingForInitialData</c> for an
|
||||||
|
/// authored-but-unreported id, <c>BadNodeIdUnknown</c> for an unknown one,
|
||||||
|
/// <c>BadNoCommunication</c> for the Agent's <c>UNAVAILABLE</c> — are passed through
|
||||||
|
/// unmodified. They tell an operator three different things.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="ArgumentNullException">
|
||||||
|
/// <paramref name="fullReferences"/> is <c>null</c> — a caller bug, not device data, and the
|
||||||
|
/// one thing this method is loud about (matching <see cref="MTConnectObservationIndex"/>'s
|
||||||
|
/// documented posture).
|
||||||
|
/// </exception>
|
||||||
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <c>/probe</c> device model: the cached copy when warm, otherwise a fresh
|
/// The <c>/probe</c> device model: the cached copy when warm, otherwise a fresh
|
||||||
/// deadline-bounded <c>/probe</c> that re-populates the cache. This is the accessor every
|
/// deadline-bounded <c>/probe</c> that re-populates the cache. This is the accessor every
|
||||||
@@ -609,6 +738,45 @@ public sealed class MTConnectDriver : IDriver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes a read failure to the health surface (05/STAB-9's fleet-wide shape): log it and
|
||||||
|
/// degrade, preserving <c>LastSuccessfulRead</c> — the "when did this driver last hear from
|
||||||
|
/// the Agent" diagnostic. Never downgrades <see cref="DriverState.Faulted"/>, which is a
|
||||||
|
/// stronger, config-level signal that a transient read failure must not paper over.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static void SafeDispose(IMTConnectAgentClient? client)
|
||||||
{
|
{
|
||||||
if (client is null)
|
if (client is null)
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
/// <summary>When set, <c>/current</c> throws this instead of answering.</summary>
|
/// <summary>When set, <c>/current</c> throws this instead of answering.</summary>
|
||||||
public Exception? CurrentFailure { get; set; }
|
public Exception? CurrentFailure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, <c>/current</c> 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 <c>null</c> for the ordinary immediate answer.
|
||||||
|
/// </summary>
|
||||||
|
public TaskCompletionSource? CurrentGate { get; set; }
|
||||||
|
|
||||||
/// <summary>Number of <c>/probe</c> requests issued against this client.</summary>
|
/// <summary>Number of <c>/probe</c> requests issued against this client.</summary>
|
||||||
public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
|
public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
|
||||||
|
|
||||||
@@ -110,15 +118,21 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
Interlocked.Increment(ref _currentCallCount);
|
Interlocked.Increment(ref _currentCallCount);
|
||||||
|
|
||||||
return CurrentFailure is null
|
// The request was accepted; the answer is withheld until the test releases the gate or the
|
||||||
? Task.FromResult(Current)
|
// caller's deadline cancels the token.
|
||||||
: Task.FromException<MTConnectStreamsResult>(CurrentFailure);
|
var gate = CurrentGate;
|
||||||
|
if (gate is not null)
|
||||||
|
{
|
||||||
|
await gate.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CurrentFailure is null ? Current : throw CurrentFailure;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 10 — <see cref="MTConnectDriver"/>'s <see cref="IReadable"/> half: one <c>/current</c>
|
||||||
|
/// per batch, one snapshot per requested reference in request order, and a Bad code for every
|
||||||
|
/// shape of failure. Every test runs against <see cref="CannedAgentClient"/>; no socket is
|
||||||
|
/// opened anywhere in this file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The load-bearing assertions are about arity, order, and not-throwing.</b> The caller
|
||||||
|
/// (the runtime's dispatch layer) indexes the returned list <i>positionally</i> 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
|
||||||
|
/// <see cref="IReadable.ReadAsync"/> fails the whole batch including the references that
|
||||||
|
/// were perfectly readable.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The one exception that may propagate is genuine caller cancellation</b>, and it is
|
||||||
|
/// pinned here alongside its opposite (an agent failure under an uncancelled token) — the
|
||||||
|
/// two are one <c>catch</c> filter apart in the implementation, and getting the filter
|
||||||
|
/// backwards turns every dead agent into a thrown batch.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authored tags spanning every read outcome the fixtures can produce: a coercible Good
|
||||||
|
/// value, an <c>UNAVAILABLE</c> one, a TIME_SERIES vector, and one authored id no fixture
|
||||||
|
/// ever reports.
|
||||||
|
/// </summary>
|
||||||
|
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 ----
|
||||||
|
|
||||||
|
/// <summary>The plan's TDD case: one snapshot per ref, in order, absent ⇒ Bad rather than a throw.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An empty batch is empty — and, being empty, costs the Agent nothing.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A batch of N references costs exactly ONE <c>/current</c>. The whole point of reading the
|
||||||
|
/// Agent's whole-device snapshot is that per-tag round-trips never happen.
|
||||||
|
/// </summary>
|
||||||
|
[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 ----
|
||||||
|
|
||||||
|
/// <summary>A genuinely unknown id — neither authored nor ever observed.</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An authored id the Agent has never reported is <c>BadWaitingForInitialData</c>, NOT
|
||||||
|
/// <c>BadNodeIdUnknown</c> — collapsing the two would tell an operator their correctly
|
||||||
|
/// authored tag does not exist.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Agent said <c>UNAVAILABLE</c>: reachable Agent, absent machine value.</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral).</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A blank reference is data, not a programming error — it codes Bad, it does not throw.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The authored type is honoured: the Agent's text becomes a CLR value of that type.</summary>
|
||||||
|
[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<double>().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 ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The whole justification for spending a round-trip: an OPC UA Read must reflect the
|
||||||
|
/// device's <i>current</i> state, not whatever <see cref="MTConnectDriver.InitializeAsync"/>
|
||||||
|
/// happened to prime. A driver that served the primed index would pass every other test in
|
||||||
|
/// this file.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The anti-clobber pin.</b> <c>ReadAsync</c> indexes its <c>/current</c> into a
|
||||||
|
/// per-call snapshot and never writes the driver's shared observation index — that index has
|
||||||
|
/// exactly one writer, the Task 11 <c>/sample</c> pump. If a read wrote it, a <c>/current</c>
|
||||||
|
/// document older than the pump's newest delta would silently roll a subscribed value
|
||||||
|
/// backwards and leave it there until the next change.
|
||||||
|
/// </summary>
|
||||||
|
[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 ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An unreachable Agent fails the batch's values, not the batch. Every reference codes
|
||||||
|
/// <c>BadCommunicationError</c> and the call returns normally.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An answer the driver cannot make sense of is a Bad batch, not a thrown one.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A failed read degrades the driver rather than leaving it advertising Healthy, and a later
|
||||||
|
/// successful read restores it. <c>LastSuccessfulRead</c> survives the degradation — it is
|
||||||
|
/// the "when did this driver last hear from the Agent" diagnostic.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R2-01 — the <c>/current</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one exception that may cross the capability boundary. Note the contrast with
|
||||||
|
/// <see cref="Read_codes_every_ref_bad_when_the_agent_call_fails"/>: same code path, one
|
||||||
|
/// <c>catch</c> filter apart.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_propagates_genuine_caller_cancellation()
|
||||||
|
{
|
||||||
|
var (driver, _) = await InitializedDriverAsync();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
await cts.CancelAsync();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(
|
||||||
|
() => driver.ReadAsync(["dev1_pos"], cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reads outside the initialized window ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A read before <c>InitializeAsync</c> has no Agent to ask. It reports
|
||||||
|
/// <c>BadNotConnected</c> for every reference — it does not throw, it does not
|
||||||
|
/// <c>NullReferenceException</c>, and it does not invent a value.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same posture after <c>ShutdownAsync</c>, and shutting down must stay reported as
|
||||||
|
/// <see cref="DriverState.Unknown"/> — a read attempted against a deliberately stopped
|
||||||
|
/// driver is not a degradation.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <c>null</c> reference list is a caller bug, not device data — the one place this method
|
||||||
|
/// is allowed to be loud, matching <see cref="MTConnectObservationIndex"/>'s documented
|
||||||
|
/// posture ("the only exceptions raised are <c>ArgumentNullException</c> for a null argument").
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_of_a_null_ref_list_throws_ArgumentNullException()
|
||||||
|
{
|
||||||
|
var (driver, _) = await InitializedDriverAsync();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(
|
||||||
|
() => driver.ReadAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -1,5 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!-- OTOPCUA0001 (arch-review 07/C-1): this suite invokes driver capability methods DIRECTLY to
|
||||||
|
exercise wire-level behavior — the analyzer's documented intentional case ("move the suppression
|
||||||
|
into a NoWarn for the test project"). Not a resilience-dispatch gap. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
Reference in New Issue
Block a user