Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
T
Joseph Doherty 4fe0af3693 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.
2026-07-27 13:57:46 -04:00

549 lines
20 KiB
C#

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