using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
///
/// The shared test double: an Agent that serves the canned
/// Fixtures/probe.xml + Fixtures/current.xml documents, counts every call, records
/// its own disposal, and lets a test drive the /sample chunk sequence by hand.
///
///
///
/// Deterministic by construction — no timers, no sleeps, no polling. The
/// /sample leg reads from an unbounded that only a test
/// fills, and 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 yield return resumes). A subscription test can therefore say "one
/// chunk has now been fully processed" as a fact rather than as a timing hope.
///
///
/// It honours the seam's stream-end contract (see
/// ): cancelling the token is the only way the
/// enumeration ends without throwing. Closing the scripted stream via
/// raises , 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.
///
///
/// Disposal is observable () because "did the driver
/// actually release the client?" is a behaviour, not an implementation detail: a
/// ReinitializeAsync 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.
///
///
internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
{
private readonly CancellationTokenSource _disposeCts = new();
/// Chunks a test has scripted but not yet pumped — see .
private readonly ConcurrentQueue _script = new();
///
/// The current /sample stream generation. Replaced (not merely completed) by
/// so that a pump which reconnects after a dropped stream gets a
/// live channel to read from instead of one that is permanently closed — without that, a
/// reconnect test could only ever observe the reconnect failing.
///
private Channel _chunks = Channel.CreateUnbounded();
private int _probeCallCount;
private int _currentCallCount;
private int _sampleCallCount;
private int _disposeCount;
private CannedAgentClient(MTConnectProbeModel probe, MTConnectStreamsResult current)
{
Probe = probe;
Current = current;
}
///
/// Builds a client serving the repo's canned fixtures — Fixtures/probe.xml for
/// /probe and Fixtures/current.xml for /current.
///
/// Probe-document fixture path, relative to the test binaries.
/// Streams-document fixture path, relative to the test binaries.
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)));
/// Parses a streams fixture into a chunk a test can script onto the sample stream.
/// Streams-document fixture path, relative to the test binaries.
public static MTConnectStreamsResult Chunk(string fixture) =>
MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
///
/// An Agent scripted for the ring-buffer-overflow story: the driver primes from
/// Fixtures/current.xml (nextSequence 108), then the first scripted chunk is
/// Fixtures/sample-gap.xml — whose firstSequence (5000) is strictly newer than
/// the cursor, i.e. the buffer rolled past the driver — and the second is an ordinary chunk
/// that continues contiguously from the re-baseline.
///
///
/// The second /current answer is what makes this a story rather than a single
/// event: it advertises the buffer the gap chunk revealed (firstSequence 5000 /
/// nextSequence 5005), so a driver that re-baselines and resumes from that answer sees
/// the follow-up chunk as contiguous. A driver that re-baselined but resumed from the stale
/// cursor would report a gap on every subsequent chunk instead.
///
public static CannedAgentClient WithGapThenResume()
{
var client = FromFixtures();
var gap = Chunk("Fixtures/sample-gap.xml");
// 1st /current = the InitializeAsync prime (the fixture). 2nd = the post-gap re-baseline.
client.CurrentAnswers.Enqueue(client.Current);
client.CurrentAnswers.Enqueue(
new MTConnectStreamsResult(gap.InstanceId, gap.NextSequence, gap.FirstSequence, gap.Observations));
client.ScriptChunks(
gap,
new MTConnectStreamsResult(
gap.InstanceId,
gap.NextSequence + 5,
gap.NextSequence,
[
new MTConnectObservation(
"dev1_pos", "201.5000", new DateTime(2026, 7, 24, 12, 6, 0, DateTimeKind.Utc)),
]));
return client;
}
/// The document /probe answers with. Settable so a test can re-shape the model.
public MTConnectProbeModel Probe { get; set; }
///
/// The document /current answers with. Settable so a re-baseline (or an agent-restart
/// instanceId change) can be scripted between calls.
///
public MTConnectStreamsResult Current { get; set; }
/// When set, /probe throws this instead of answering.
public Exception? ProbeFailure { get; set; }
/// When set, /current throws this instead of answering.
public Exception? CurrentFailure { get; set; }
///
/// Scripted /current answers, consumed in order: each call dequeues the next one and
/// promotes it to , so once the script runs out the Agent keeps
/// answering with the last thing it said rather than travelling back in time. This is how a
/// test scripts "the prime, then the post-gap re-baseline" without racing the pump for a
/// property setter.
///
public ConcurrentQueue CurrentAnswers { get; } = new();
///
/// Scripted one-shot /sample failures: each enumeration dequeues one and throws it.
/// Models a transient stream fault the Agent recovers from — e.g. the
/// a real cppagent's OUT_OF_RANGE error document
/// (served under HTTP 200) surfaces as.
///
public ConcurrentQueue SampleFailures { get; } = new();
///
/// A sticky /sample failure: every enumeration throws it, forever. Models a
/// configuration-level fault such as
/// , where reconnecting reproduces the
/// identical answer — the shape a pump must NOT retry-loop against.
///
public Exception? SampleFailure { get; set; }
///
/// When set, the next /probe parks here until the test completes it, then the
/// gate clears itself so later calls answer immediately.
///
///
/// This is how a test holds a request "in flight" across a concurrent lifecycle change —
/// a shutdown or a re-initialize landing mid-fetch — with no timers and no races of its own.
/// The answer is captured when the request lands, not when it completes, so a test can
/// change meanwhile and still tell the two documents apart.
///
public TaskCompletionSource? ProbeGate { 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; }
///
/// Signalled the moment a /current request lands — before
/// parks it. This is the deterministic "the caller is now inside
/// the request" barrier a test needs before landing a concurrent lifecycle change on it;
/// without it the only alternative is polling on a timer.
///
public TaskCompletionSource? CurrentEntered { get; set; }
/// Number of /probe requests issued against this client.
public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
/// Number of /current requests issued against this client.
public int CurrentCallCount => Volatile.Read(ref _currentCallCount);
/// Number of /sample enumerations started against this client.
public int SampleCallCount => Volatile.Read(ref _sampleCallCount);
/// Number of calls (not clamped — a double-dispose is visible).
public int DisposeCount => Volatile.Read(ref _disposeCount);
/// Whether the client has been disposed at least once.
public bool IsDisposed => DisposeCount > 0;
/// The from sequence the most recent /sample enumeration was opened at.
public long? LastSampleFrom { get; private set; }
///
public async Task ProbeAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref _probeCallCount);
if (ProbeFailure is not null)
{
throw ProbeFailure;
}
// Captured now: the Agent answers with the document it held when the request landed.
var answer = Probe;
if (ProbeGate is { } gate)
{
ProbeGate = null;
await gate.Task.WaitAsync(ct).ConfigureAwait(false);
// A real client whose handler was disposed mid-request fails the request; so does this.
ObjectDisposedException.ThrowIf(IsDisposed, this);
}
return answer;
}
///
public async Task CurrentAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ct.ThrowIfCancellationRequested();
Interlocked.Increment(ref _currentCallCount);
CurrentEntered?.TrySetResult();
// 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);
}
// A scripted answer supersedes — and then becomes — the standing one.
if (CurrentAnswers.TryDequeue(out var scripted))
{
Current = scripted;
}
return CurrentFailure is null ? Current : throw CurrentFailure;
}
///
public async IAsyncEnumerable SampleAsync(
long from, [EnumeratorCancellation] CancellationToken ct)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
Interlocked.Increment(ref _sampleCallCount);
LastSampleFrom = from;
// Thrown before the first chunk, exactly like a real client that fails its /sample request:
// one-shot scripts first, then the sticky "this endpoint will never stream" failure.
if (SampleFailures.TryDequeue(out var oneShot))
{
throw oneShot;
}
if (SampleFailure is { } sticky)
{
throw sticky;
}
// Bound to THIS generation: EndStream swaps in a fresh channel, so a consumer that
// reconnects reads the new one rather than the closed one it just lost.
var chunks = Volatile.Read(ref _chunks);
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++;
try
{
yield return scripted.Result;
}
finally
{
// Signalled when the consumer is DONE with this chunk — whether it came back for the
// next one or abandoned the enumeration. The finally is load-bearing: a chunk that
// makes the pump break out (a sequence gap, an agent restart) is never resumed past,
// so signalling only after the resume would hang every re-baseline test.
scripted.Consumed.TrySetResult();
}
}
}
///
/// Queues a chunk onto the scripted /sample stream and waits until the consumer has
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
/// ran".
///
/// The chunk the Agent should send next.
/// A task completing once the enumerating consumer has moved past .
public Task PumpAsync(MTConnectStreamsResult chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
var scripted = new ScriptedChunk(
chunk, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
if (!Volatile.Read(ref _chunks).Writer.TryWrite(scripted))
{
throw new InvalidOperationException("The scripted /sample stream is already closed.");
}
return scripted.Consumed.Task;
}
///
/// Queues chunks for to deliver one at a time. Nothing is sent until
/// a test asks for it — the script is a plan, not a schedule.
///
/// The chunks the Agent should send, in order.
public void ScriptChunks(params MTConnectStreamsResult[] chunks)
{
ArgumentNullException.ThrowIfNull(chunks);
foreach (var chunk in chunks)
{
_script.Enqueue(chunk);
}
}
///
/// Pumps the next scripted chunk (see ) and waits until the
/// consumer has finished with it.
///
/// A task completing once the consumer has processed — or abandoned the stream on — that chunk.
/// The script is exhausted.
public Task PumpOnce() =>
_script.TryDequeue(out var next)
? PumpAsync(next)
: throw new InvalidOperationException(
"No scripted /sample chunk left to pump; call ScriptChunks first.");
///
/// Closes the current scripted /sample stream, which surfaces to its consumer as an
/// — the transient, reconnectable end — and opens
/// a fresh one so a reconnecting consumer has somewhere to land.
///
public void EndStream()
{
var closed = Interlocked.Exchange(ref _chunks, Channel.CreateUnbounded());
closed.Writer.TryComplete();
}
///
public void Dispose()
{
Interlocked.Increment(ref _disposeCount);
if (DisposeCount > 1)
{
return;
}
_disposeCts.Cancel();
Volatile.Read(ref _chunks).Writer.TryComplete();
_disposeCts.Dispose();
}
private sealed record ScriptedChunk(MTConnectStreamsResult Result, TaskCompletionSource Consumed);
}