feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9)
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.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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);
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 9 — <see cref="MTConnectDriver"/>'s <see cref="IDriver"/> lifecycle over the
|
||||
/// <see cref="IMTConnectAgentClient"/> seam: construction, initialize, reinitialize, shutdown,
|
||||
/// health, footprint, and cache flush. Every test runs against
|
||||
/// <see cref="CannedAgentClient"/>; no socket is opened anywhere in this file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The load-bearing assertions here are the ones about <b>what must NOT happen</b>: a
|
||||
/// constructor that dials, an initialize that faults but still reports Healthy, a re-point that
|
||||
/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
|
||||
/// defect the happy-path assertions alone would pass straight over.
|
||||
/// </remarks>
|
||||
public sealed class MTConnectDriverLifecycleTests
|
||||
{
|
||||
private const string AgentUri = "http://fixture-agent:5000";
|
||||
|
||||
/// <summary>Every dataItemId the canned probe declares — the browse-cache footprint basis.</summary>
|
||||
private const int FixtureDataItemCount = 7;
|
||||
|
||||
private static MTConnectDriverOptions Opts(
|
||||
string agentUri = AgentUri,
|
||||
string? deviceName = null,
|
||||
int requestTimeoutMs = 5000) =>
|
||||
new()
|
||||
{
|
||||
AgentUri = agentUri,
|
||||
DeviceName = deviceName,
|
||||
RequestTimeoutMs = requestTimeoutMs,
|
||||
Tags =
|
||||
[
|
||||
new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
|
||||
new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
|
||||
],
|
||||
};
|
||||
|
||||
/// <summary>A driver wired to one canned client, plus that client, for the common case.</summary>
|
||||
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
|
||||
MTConnectDriverOptions? options = null)
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
|
||||
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
|
||||
}
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync()
|
||||
{
|
||||
var (driver, client) = NewDriver();
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
return (driver, client);
|
||||
}
|
||||
|
||||
// ---- connection-free construction ----
|
||||
|
||||
[Fact]
|
||||
public void Constructor_dials_nothing_and_does_not_even_build_a_client()
|
||||
{
|
||||
var factoryCalls = 0;
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
|
||||
return client;
|
||||
});
|
||||
|
||||
// The universal discovery browser constructs a throwaway instance purely to ask CanBrowse.
|
||||
// If the ctor built (or worse, dialled) a client, every browse probe would open a connection
|
||||
// pool against a possibly-unreachable Agent.
|
||||
factoryCalls.ShouldBe(0);
|
||||
client.ProbeCallCount.ShouldBe(0);
|
||||
client.CurrentCallCount.ShouldBe(0);
|
||||
client.SampleCallCount.ShouldBe(0);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identity_is_the_constructor_arguments()
|
||||
{
|
||||
var (driver, _) = NewDriver();
|
||||
|
||||
driver.DriverInstanceId.ShouldBe("mt1");
|
||||
driver.DriverType.ShouldBe("MTConnect");
|
||||
}
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_primes_index_and_reports_healthy()
|
||||
{
|
||||
var (driver, client) = NewDriver();
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
client.CurrentCallCount.ShouldBe(1);
|
||||
|
||||
// The /current snapshot is what makes the driver able to answer a read at all.
|
||||
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_records_the_agent_instance_id_and_the_probe_model()
|
||||
{
|
||||
var (driver, client) = NewDriver();
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
driver.AgentInstanceId.ShouldBe(client.Current.InstanceId);
|
||||
driver.CachedProbeModel.ShouldNotBeNull();
|
||||
driver.CachedProbeModel!.Devices.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_stamps_last_successful_read_between_the_call_boundaries()
|
||||
{
|
||||
var (driver, _) = NewDriver();
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
var lastRead = driver.GetHealth().LastSuccessfulRead;
|
||||
lastRead.ShouldNotBeNull();
|
||||
lastRead!.Value.ShouldBeGreaterThanOrEqualTo(before);
|
||||
lastRead.Value.ShouldBeLessThanOrEqualTo(after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_faults_and_rethrows_when_probe_fails()
|
||||
{
|
||||
var (driver, client) = NewDriver();
|
||||
client.ProbeFailure = new HttpRequestException("agent unreachable");
|
||||
|
||||
var thrown = await Should.ThrowAsync<HttpRequestException>(
|
||||
() => driver.InitializeAsync("{}", CancellationToken.None));
|
||||
|
||||
thrown.Message.ShouldBe("agent unreachable");
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNull();
|
||||
health.LastError!.ShouldContain("agent unreachable");
|
||||
|
||||
// No half-initialized object: the client it built must not survive the failure.
|
||||
client.IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails()
|
||||
{
|
||||
// Deliberate posture (documented on InitializeAsync): a driver whose /probe worked but whose
|
||||
// priming /current did not has an EMPTY value index and no sample cursor. Reporting Healthy
|
||||
// there would render every tag BadWaitingForInitialData forever while the dashboard shows a
|
||||
// green driver — the #485 "a failure that looks like success" shape.
|
||||
var (driver, client) = NewDriver();
|
||||
client.CurrentFailure = new InvalidDataException("current unparseable");
|
||||
|
||||
await Should.ThrowAsync<InvalidDataException>(
|
||||
() => driver.InitializeAsync("{}", CancellationToken.None));
|
||||
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
|
||||
driver.CachedProbeModel.ShouldBeNull();
|
||||
driver.ObservationIndex.Count.ShouldBe(0);
|
||||
client.IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_rejects_a_non_positive_request_timeout_before_dialling()
|
||||
{
|
||||
// arch-review 01/S-6: an operator-authorable 0 must never come to mean "wait forever".
|
||||
// Asserted through a FAKE client factory, so this proves the DRIVER validates rather than
|
||||
// relying on the production client's own constructor guard.
|
||||
var factoryCalls = 0;
|
||||
var driver = new MTConnectDriver(Opts(requestTimeoutMs: 0), "mt1", _ =>
|
||||
{
|
||||
factoryCalls++;
|
||||
|
||||
return CannedAgentClient.FromFixtures();
|
||||
});
|
||||
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
||||
() => driver.InitializeAsync("{}", CancellationToken.None));
|
||||
|
||||
factoryCalls.ShouldBe(0);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
// ---- the driverConfigJson argument ----
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
|
||||
{
|
||||
// The runtime delivers a config change ONLY through this argument (DriverInstanceActor
|
||||
// ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every
|
||||
// MTConnect config change a silent no-op that still seals the deployment green.
|
||||
MTConnectDriverOptions? seen = null;
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||
{
|
||||
seen = o;
|
||||
|
||||
return CannedAgentClient.FromFixtures();
|
||||
});
|
||||
|
||||
await driver.InitializeAsync(
|
||||
"""{"agentUri":"http://other-agent:5001","deviceName":"VMC-3Axis"}""",
|
||||
CancellationToken.None);
|
||||
|
||||
seen.ShouldNotBeNull();
|
||||
seen!.AgentUri.ShouldBe("http://other-agent:5001");
|
||||
seen.DeviceName.ShouldBe("VMC-3Axis");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_with_an_empty_config_body_keeps_the_constructor_options()
|
||||
{
|
||||
MTConnectDriverOptions? seen = null;
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||
{
|
||||
seen = o;
|
||||
|
||||
return CannedAgentClient.FromFixtures();
|
||||
});
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
seen.ShouldNotBeNull();
|
||||
seen!.AgentUri.ShouldBe(AgentUri);
|
||||
seen.Tags.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_faults_on_a_config_body_with_no_agent_uri()
|
||||
{
|
||||
var (driver, _) = NewDriver();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.InitializeAsync("""{"deviceName":"VMC-3Axis"}""", CancellationToken.None));
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_parses_authored_tags_out_of_the_config_json()
|
||||
{
|
||||
MTConnectDriverOptions? seen = null;
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||
{
|
||||
seen = o;
|
||||
|
||||
return CannedAgentClient.FromFixtures();
|
||||
});
|
||||
|
||||
await driver.InitializeAsync(
|
||||
"""
|
||||
{"agentUri":"http://a:5000",
|
||||
"tags":[{"fullName":"dev1_partcount","driverDataType":"Int32"}]}
|
||||
""",
|
||||
CancellationToken.None);
|
||||
|
||||
seen.ShouldNotBeNull();
|
||||
seen!.Tags.Count.ShouldBe(1);
|
||||
seen.Tags[0].FullName.ShouldBe("dev1_partcount");
|
||||
|
||||
// Enum-serialization trap (project-wide MEMORY): enum-carrying config fields are NAMES.
|
||||
seen.Tags[0].DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
// ---- reinitialize ----
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_unchanged_endpoint_config_keeps_the_same_client()
|
||||
{
|
||||
var built = new List<CannedAgentClient>();
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
||||
{
|
||||
var c = CannedAgentClient.FromFixtures();
|
||||
built.Add(c);
|
||||
|
||||
return c;
|
||||
});
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await driver.ReinitializeAsync(
|
||||
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
|
||||
CancellationToken.None);
|
||||
|
||||
// Config-only: the live connection pool survives, so no client churn...
|
||||
built.Count.ShouldBe(1);
|
||||
built[0].IsDisposed.ShouldBeFalse();
|
||||
|
||||
// ...but everything DERIVED from config is genuinely rebuilt against the new document.
|
||||
built[0].ProbeCallCount.ShouldBe(2);
|
||||
built[0].CurrentCallCount.ShouldBe(2);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_a_changed_agent_uri_tears_down_and_rebuilds_the_client()
|
||||
{
|
||||
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||
{
|
||||
var c = CannedAgentClient.FromFixtures();
|
||||
built.Add((o, c));
|
||||
|
||||
return c;
|
||||
});
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await driver.ReinitializeAsync(
|
||||
"""{"agentUri":"http://relocated-agent:5000"}""", CancellationToken.None);
|
||||
|
||||
// A re-pointed driver that kept the old client would keep reading the OLD machine while the
|
||||
// deployment reports success.
|
||||
built.Count.ShouldBe(2);
|
||||
built[0].Client.IsDisposed.ShouldBeTrue();
|
||||
built[1].Options.AgentUri.ShouldBe("http://relocated-agent:5000");
|
||||
built[1].Client.IsDisposed.ShouldBeFalse();
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_a_changed_device_scope_rebuilds_the_client()
|
||||
{
|
||||
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||
{
|
||||
var c = CannedAgentClient.FromFixtures();
|
||||
built.Add((o, c));
|
||||
|
||||
return c;
|
||||
});
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await driver.ReinitializeAsync(
|
||||
$$"""{"agentUri":"{{AgentUri}}","deviceName":"VMC-3Axis"}""", CancellationToken.None);
|
||||
|
||||
// DeviceName is baked into every request URI at client construction.
|
||||
built.Count.ShouldBe(2);
|
||||
built[0].Client.IsDisposed.ShouldBeTrue();
|
||||
built[1].Options.DeviceName.ShouldBe("VMC-3Axis");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_changed_request_knobs_rebuilds_the_client()
|
||||
{
|
||||
// RequestTimeoutMs / HeartbeatMs / SampleIntervalMs / SampleCount are all read by the client
|
||||
// CONSTRUCTOR (deadlines, watchdog window, /sample query string). Keeping the old client
|
||||
// because the URI happened not to change would silently discard the operator's edit.
|
||||
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||
{
|
||||
var c = CannedAgentClient.FromFixtures();
|
||||
built.Add((o, c));
|
||||
|
||||
return c;
|
||||
});
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
await driver.ReinitializeAsync(
|
||||
$$"""{"agentUri":"{{AgentUri}}","heartbeatMs":2500}""", CancellationToken.None);
|
||||
|
||||
built.Count.ShouldBe(2);
|
||||
built[1].Options.HeartbeatMs.ShouldBe(2500);
|
||||
built[0].Client.IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_before_initialize_behaves_as_initialize()
|
||||
{
|
||||
var (driver, client) = NewDriver();
|
||||
|
||||
await driver.ReinitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_that_fails_leaves_the_driver_faulted_not_healthy()
|
||||
{
|
||||
var built = new List<CannedAgentClient>();
|
||||
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
||||
{
|
||||
var c = CannedAgentClient.FromFixtures();
|
||||
built.Add(c);
|
||||
|
||||
return c;
|
||||
});
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
built[0].CurrentFailure = new HttpRequestException("agent went away");
|
||||
|
||||
// Config-only shape (only the tag list changes), so the failing leg is the re-prime against
|
||||
// the client that is already live — the path a rebuild would otherwise mask.
|
||||
await Should.ThrowAsync<HttpRequestException>(
|
||||
() => driver.ReinitializeAsync(
|
||||
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos"}]}""", CancellationToken.None));
|
||||
|
||||
built.Count.ShouldBe(1);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("agent went away");
|
||||
|
||||
// A failed refresh must not leave a live client nobody owns.
|
||||
built[0].IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
|
||||
{
|
||||
// Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must
|
||||
// not down a driver that is happily serving its previous, valid configuration.
|
||||
var (driver, client) = await InitializedDriverAsync();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.ReinitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
|
||||
client.IsDisposed.ShouldBeFalse();
|
||||
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||
}
|
||||
|
||||
// ---- shutdown ----
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_disposes_the_client_and_drops_the_served_state()
|
||||
{
|
||||
var (driver, client) = await InitializedDriverAsync();
|
||||
var lastRead = driver.GetHealth().LastSuccessfulRead;
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
client.DisposeCount.ShouldBe(1);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
// Retained: it is diagnostic ("when did we last hear from the Agent"), not served data.
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(lastRead);
|
||||
|
||||
// Dropped: values from a torn-down connection must never keep reading Good.
|
||||
driver.ObservationIndex.Count.ShouldBe(0);
|
||||
driver.CachedProbeModel.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_is_idempotent()
|
||||
{
|
||||
var (driver, client) = await InitializedDriverAsync();
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
client.DisposeCount.ShouldBe(1);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_before_initialize_is_a_no_op()
|
||||
{
|
||||
var (driver, client) = NewDriver();
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
client.DisposeCount.ShouldBe(0);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
// ---- footprint + flush ----
|
||||
|
||||
[Fact]
|
||||
public async Task Memory_footprint_is_zero_before_initialize_and_positive_after()
|
||||
{
|
||||
var (driver, _) = NewDriver();
|
||||
|
||||
var before = driver.GetMemoryFootprint();
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
var after = driver.GetMemoryFootprint();
|
||||
|
||||
// Before init the only config-attributable state is the two authored tags.
|
||||
before.ShouldBeLessThan(after);
|
||||
after.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index()
|
||||
{
|
||||
var (driver, _) = await InitializedDriverAsync();
|
||||
var before = driver.GetMemoryFootprint();
|
||||
|
||||
await driver.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
|
||||
driver.GetMemoryFootprint().ShouldBeLessThan(before);
|
||||
driver.CachedProbeModel.ShouldBeNull();
|
||||
|
||||
// The index is required for correctness (it IS the read surface) — never flushable.
|
||||
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
|
||||
{
|
||||
// Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a
|
||||
// cache-budget breach would permanently disable browse on that driver instance.
|
||||
var (driver, client) = await InitializedDriverAsync();
|
||||
await driver.FlushOptionalCachesAsync(CancellationToken.None);
|
||||
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
|
||||
var model = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
||||
|
||||
model.Devices.Count.ShouldBe(1);
|
||||
client.ProbeCallCount.ShouldBe(2);
|
||||
driver.CachedProbeModel.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_model_is_served_from_cache_while_it_is_warm()
|
||||
{
|
||||
var (driver, client) = await InitializedDriverAsync();
|
||||
|
||||
_ = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
||||
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetching_the_probe_model_before_initialize_is_rejected_rather_than_silently_empty()
|
||||
{
|
||||
var (driver, _) = NewDriver();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user