test(adminui): E2E deployed-driver Healthy->Reconnecting->Healthy transition on Reconnect

This commit is contained in:
Joseph Doherty
2026-06-18 09:12:15 -04:00
parent 4e649151ac
commit f87ad5ae8f
2 changed files with 262 additions and 19 deletions
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -15,24 +16,38 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// initialize/connect succeeds, so the wrapping <c>DriverInstanceActor</c> walks
/// <c>Connecting → Connected → Healthy</c> and (on <c>ReconnectDriver</c>) drives
/// <c>ForceReconnect → Reconnecting → re-initialize → Connected</c> without any fault injection.
/// Each created driver is recorded in <see cref="Created"/> so a test can reach in and flip its
/// health to <see cref="DriverState.Reconnecting"/> just before issuing the reconnect command.
/// </remarks>
public sealed class FakeReconnectDriverFactory : IDriverFactory
{
/// <summary>The single driver type this fake factory materialises.</summary>
public const string FakeDriverType = "Modbus";
/// <summary>
/// Drivers this factory has created, keyed by <c>driverInstanceId</c>, so a test can retrieve the
/// live instance (e.g. to call <see cref="FakeReconnectDriver.ReportReconnecting"/> before
/// dispatching a reconnect command). Concurrent because the factory is invoked on the spawning
/// actor thread while the test reads on its own thread.
/// </summary>
public ConcurrentDictionary<string, FakeReconnectDriver> Created { get; } = new();
/// <summary>
/// Returns a <see cref="FakeReconnectDriver"/> when <paramref name="driverType"/> is
/// <see cref="FakeDriverType"/>; otherwise <c>null</c> (the host logs + skips the row).
/// <see cref="FakeDriverType"/>; otherwise <c>null</c> (the host logs + skips the row). Records the
/// created driver in <see cref="Created"/> keyed by <paramref name="driverInstanceId"/>.
/// </summary>
/// <param name="driverType">The driver type name from the deployed <c>DriverInstance</c> row.</param>
/// <param name="driverInstanceId">The stable driver-instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string (ignored by the fake).</param>
/// <returns>A new <see cref="FakeReconnectDriver"/>, or <c>null</c> for an unsupported type.</returns>
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
=> driverType == FakeDriverType
? new FakeReconnectDriver(driverInstanceId, driverType)
: null;
{
if (driverType != FakeDriverType) return null;
var driver = new FakeReconnectDriver(driverInstanceId, driverType);
Created[driverInstanceId] = driver;
return driver;
}
/// <summary>Gets the driver-type names this factory can materialise.</summary>
public IReadOnlyCollection<string> SupportedTypes { get; } = new[] { FakeDriverType };
@@ -43,6 +58,14 @@ public sealed class FakeReconnectDriverFactory : IDriverFactory
/// wrapping <c>DriverInstanceActor</c> reaches <c>Connected</c> and publishes a
/// <see cref="DriverState.Healthy"/> snapshot. Reads/writes/subscribes are benign no-ops; this exists
/// only to let a deployed driver walk the Healthy ↔ Reconnecting FSM in-process for E2E tests.
///
/// <para>The driver's reported health is controllable: a test calls <see cref="ReportReconnecting"/>
/// to make <see cref="GetHealth"/> return <see cref="DriverState.Reconnecting"/> (simulating a lost
/// connection — the realistic trigger for an operator Reconnect). The <c>DriverInstanceActor</c>'s
/// <c>ForceReconnect</c> handler POLLS <see cref="GetHealth"/> right after entering its Reconnecting
/// state, so that snapshot surfaces <see cref="DriverState.Reconnecting"/> on the driver-health topic.
/// The subsequent retry calls <see cref="InitializeAsync"/>, which clears the flag back to
/// <see cref="DriverState.Healthy"/>, so the next snapshot returns to Healthy.</para>
/// </summary>
public sealed class FakeReconnectDriver : IDriver
{
@@ -63,16 +86,48 @@ public sealed class FakeReconnectDriver : IDriver
/// <summary>Gets the driver type name (e.g. "Modbus").</summary>
public string DriverType { get; }
/// <summary>
/// When <c>true</c>, <see cref="GetHealth"/> reports <see cref="DriverState.Reconnecting"/>.
/// <c>volatile</c> because the actor polls <see cref="GetHealth"/> from its own thread while the
/// test flips this from another via <see cref="ReportReconnecting"/>.
/// </summary>
private volatile bool _reconnecting;
/// <summary>Timestamp of the most recent successful initialize; surfaced as the last successful read.</summary>
private DateTime _lastSuccess = DateTime.UtcNow;
/// <summary>
/// Number of times <see cref="InitializeAsync"/> has been invoked. Read by the test to prove a
/// reconnect genuinely re-initialised the driver through the full cluster path (≥ 2 means the
/// initial connect plus at least one reconnect retry). Mutated via <see cref="Interlocked"/> since
/// the actor's retry path runs on a thread-pool thread.
/// </summary>
public int InitializeCount;
/// <summary>
/// Marks the driver as having lost its connection so the next <see cref="GetHealth"/> poll reports
/// <see cref="DriverState.Reconnecting"/>. The test calls this immediately before dispatching the
/// reconnect command, simulating the realistic operator-Reconnect trigger.
/// </summary>
public void ReportReconnecting() => _reconnecting = true;
/// <summary>
/// Connect/initialize path — always succeeds (returns a completed task), so the actor self-Tells
/// <c>InitializeSucceeded</c> and becomes <c>Connected</c>. This is the method that makes connect
/// succeed; the FSM's reconnect path re-invokes it and it succeeds again.
/// succeed; the FSM's reconnect path re-invokes it and it succeeds again. Increments
/// <see cref="InitializeCount"/> and clears the reconnecting flag (initialize succeeded → healthy
/// again).
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (ignored).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task — initialization always succeeds.</returns>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
=> Task.CompletedTask;
{
Interlocked.Increment(ref InitializeCount);
_lastSuccess = DateTime.UtcNow;
_reconnecting = false;
return Task.CompletedTask;
}
/// <summary>Applies a config change in place — a no-op that always succeeds.</summary>
/// <param name="driverConfigJson">The driver configuration JSON (ignored).</param>
@@ -87,9 +142,16 @@ public sealed class FakeReconnectDriver : IDriver
public Task ShutdownAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Returns a <see cref="DriverState.Healthy"/> snapshot so deployed drivers publish health.</summary>
/// <returns>A <see cref="DriverHealth"/> in the <see cref="DriverState.Healthy"/> state.</returns>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>
/// Returns a <see cref="DriverState.Reconnecting"/> snapshot when the test has flagged a lost
/// connection via <see cref="ReportReconnecting"/>; otherwise a <see cref="DriverState.Healthy"/>
/// snapshot. The actor polls this on every observable state change, so the published state tracks
/// this flag.
/// </summary>
/// <returns>A <see cref="DriverHealth"/> reflecting the controllable connection state.</returns>
public DriverHealth GetHealth() => _reconnecting
? new DriverHealth(DriverState.Reconnecting, _lastSuccess, null)
: new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Returns a zero memory footprint (the fake holds no driver-attributable caches).</summary>
/// <returns>Always <c>0</c>.</returns>