4fe0af3693
MTConnectDriver implements IDriver only: connection-free ctor, initialize (deadline-bounded /probe + /current, caching the device model, the agent instanceId, and a primed observation index), reinitialize, shutdown, health, footprint, and cache flush. Decisions worth knowing: - The driverConfigJson argument is HONOURED, not ignored. DriverInstanceActor delivers a config change only via ReinitializeAsync(json) on the live instance, so a driver reading only its ctor options would turn every MTConnect config edit into a silent no-op that still sealed the deployment green. ParseOptions lives on the driver; Task 15's factory must delegate to it rather than deserialize a second DTO. - /probe ok but priming /current failing is Faulted, not Healthy-with-no-data: the index IS the read surface and the /sample cursor comes from /current. - ReinitializeAsync rebuilds the client whenever ANY option the client reads at construction changed - AgentUri, DeviceName, RequestTimeoutMs, HeartbeatMs, SampleIntervalMs, SampleCount - not just the first two. The timing knobs are baked into the deadlines, the watchdog window, and the /sample query string. - An unparseable config faults a cold start but leaves a RUNNING driver serving its previous valid configuration (the deployment fails instead). - IMTConnectAgentClient now extends IDisposable rather than the driver doing `as IDisposable`, which would silently no-op against a non-disposable fake and leak an HttpClient + socket handler per re-deploy. Sync, not async: every resource behind the seam is sync-disposable, and the async unwind belongs to the SampleAsync enumerator the pump owns. - Flush drops the probe/browse cache and keeps the index; every model consumer goes through GetOrFetchProbeModelAsync so a flush cannot wedge browse. Tests: CannedAgentClient (shared, deterministic - channel-backed scripted /sample with no timers, call counts, disposal tracking, failure injection) + 27 lifecycle tests. 308/308 in the project.
203 lines
8.7 KiB
C#
203 lines
8.7 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>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 Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
|
{
|
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
|
ct.ThrowIfCancellationRequested();
|
|
Interlocked.Increment(ref _currentCallCount);
|
|
|
|
return CurrentFailure is null
|
|
? Task.FromResult(Current)
|
|
: Task.FromException<MTConnectStreamsResult>(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);
|
|
}
|