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); }