1bfc1722b3
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.
217 lines
9.4 KiB
C#
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);
|
|
}
|