Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
T
Joseph Doherty 1bfc1722b3 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.
2026-07-27 13:57:46 -04:00

217 lines
9.4 KiB
C#

using System.Runtime.CompilerServices;
using System.Threading.Channels;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// The shared <see cref="IMTConnectAgentClient"/> test double: an Agent that serves the canned
/// <c>Fixtures/probe.xml</c> + <c>Fixtures/current.xml</c> documents, counts every call, records
/// its own disposal, and lets a test drive the <c>/sample</c> chunk sequence by hand.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deterministic by construction — no timers, no sleeps, no polling.</b> The
/// <c>/sample</c> leg reads from an unbounded <see cref="Channel{T}"/> that only a test
/// fills, and <see cref="PumpAsync"/> does not return until the driver has actually consumed
/// the chunk it wrote (each chunk carries its own completion source, signalled after the
/// enumerator's <c>yield return</c> resumes). A subscription test can therefore say "one
/// chunk has now been fully processed" as a fact rather than as a timing hope.
/// </para>
/// <para>
/// <b>It honours the seam's stream-end contract</b> (see
/// <see cref="IMTConnectAgentClient.SampleAsync"/>): cancelling the token is the only way the
/// enumeration ends without throwing. Closing the scripted stream via
/// <see cref="EndStream"/> raises <see cref="MTConnectStreamEndedException"/>, exactly as the
/// production client does when an Agent drops the connection — so a pump written against the
/// fake cannot silently pass while mishandling the real one.
/// </para>
/// <para>
/// <b>Disposal is observable</b> (<see cref="DisposeCount"/>) because "did the driver
/// actually release the client?" is a behaviour, not an implementation detail: a
/// <c>ReinitializeAsync</c> that re-points the Agent but leaks the old client keeps a live
/// connection pool per re-deploy, and nothing else in the test surface would notice.
/// </para>
/// </remarks>
internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
{
private readonly Channel<ScriptedChunk> _chunks = Channel.CreateUnbounded<ScriptedChunk>();
private readonly CancellationTokenSource _disposeCts = new();
private int _probeCallCount;
private int _currentCallCount;
private int _sampleCallCount;
private int _disposeCount;
private CannedAgentClient(MTConnectProbeModel probe, MTConnectStreamsResult current)
{
Probe = probe;
Current = current;
}
/// <summary>
/// Builds a client serving the repo's canned fixtures — <c>Fixtures/probe.xml</c> for
/// <c>/probe</c> and <c>Fixtures/current.xml</c> for <c>/current</c>.
/// </summary>
/// <param name="probeFixture">Probe-document fixture path, relative to the test binaries.</param>
/// <param name="currentFixture">Streams-document fixture path, relative to the test binaries.</param>
public static CannedAgentClient FromFixtures(
string probeFixture = "Fixtures/probe.xml",
string currentFixture = "Fixtures/current.xml") =>
new(
MTConnectProbeParser.Parse(File.ReadAllText(probeFixture)),
MTConnectStreamsParser.Parse(File.ReadAllText(currentFixture)));
/// <summary>Parses a streams fixture into a chunk a test can script onto the sample stream.</summary>
/// <param name="fixture">Streams-document fixture path, relative to the test binaries.</param>
public static MTConnectStreamsResult Chunk(string fixture) =>
MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
/// <summary>The document <c>/probe</c> answers with. Settable so a test can re-shape the model.</summary>
public MTConnectProbeModel Probe { get; set; }
/// <summary>
/// The document <c>/current</c> answers with. Settable so a re-baseline (or an agent-restart
/// <c>instanceId</c> change) can be scripted between calls.
/// </summary>
public MTConnectStreamsResult Current { get; set; }
/// <summary>When set, <c>/probe</c> throws this instead of answering.</summary>
public Exception? ProbeFailure { get; set; }
/// <summary>When set, <c>/current</c> throws this instead of answering.</summary>
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>
public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
/// <summary>Number of <c>/current</c> requests issued against this client.</summary>
public int CurrentCallCount => Volatile.Read(ref _currentCallCount);
/// <summary>Number of <c>/sample</c> enumerations started against this client.</summary>
public int SampleCallCount => Volatile.Read(ref _sampleCallCount);
/// <summary>Number of <see cref="Dispose"/> calls (not clamped — a double-dispose is visible).</summary>
public int DisposeCount => Volatile.Read(ref _disposeCount);
/// <summary>Whether the client has been disposed at least once.</summary>
public bool IsDisposed => DisposeCount > 0;
/// <summary>The <c>from</c> sequence the most recent <c>/sample</c> enumeration was opened at.</summary>
public long? LastSampleFrom { get; private set; }
/// <inheritdoc/>
public Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref _probeCallCount);
return ProbeFailure is null ? Task.FromResult(Probe) : Task.FromException<MTConnectProbeModel>(ProbeFailure);
}
/// <inheritdoc/>
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref _currentCallCount);
// 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;
}
/// <inheritdoc/>
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
long from, [EnumeratorCancellation] CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
Interlocked.Increment(ref _sampleCallCount);
LastSampleFrom = from;
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
var delivered = 0L;
while (true)
{
ScriptedChunk scripted;
try
{
scripted = await _chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false);
}
catch (ChannelClosedException)
{
// Mirrors the production client: a stream that stops for any reason other than the
// caller cancelling is an exception, never a quiet end of enumeration.
throw new MTConnectStreamEndedException(
MTConnectStreamEndReason.ConnectionClosed, "canned://agent", delivered);
}
delivered++;
yield return scripted.Result;
scripted.Consumed.TrySetResult();
}
}
/// <summary>
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
/// ran".
/// </summary>
/// <param name="chunk">The chunk the Agent should send next.</param>
/// <returns>A task completing once the enumerating consumer has moved past <paramref name="chunk"/>.</returns>
public Task PumpAsync(MTConnectStreamsResult chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
var scripted = new ScriptedChunk(
chunk, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
if (!_chunks.Writer.TryWrite(scripted))
{
throw new InvalidOperationException("The scripted /sample stream is already closed.");
}
return scripted.Consumed.Task;
}
/// <summary>
/// Closes the scripted <c>/sample</c> stream, which surfaces to the consumer as an
/// <see cref="MTConnectStreamEndedException"/> — the transient, reconnectable end.
/// </summary>
public void EndStream() => _chunks.Writer.TryComplete();
/// <inheritdoc/>
public void Dispose()
{
Interlocked.Increment(ref _disposeCount);
if (DisposeCount > 1)
{
return;
}
_disposeCts.Cancel();
_chunks.Writer.TryComplete();
_disposeCts.Dispose();
}
private sealed record ScriptedChunk(MTConnectStreamsResult Result, TaskCompletionSource Consumed);
}