using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
/// Shared stub harness used by DriverInstanceActorTests and
/// DriverInstanceActorWriteAndSubscribeTests. Promoted from the superset copy in
/// DriverInstanceActorTests so both suites compile against a single definition.
///
internal class StubDriver : IDriver
{
/// Gets or sets a value indicating whether initialization should throw.
public bool InitializeShouldThrow { get; set; }
/// Gets the number of times initialization was called.
public int InitializeCount;
/// Gets the number of times reinitialization was called.
public int ReinitializeCount;
private readonly object _initConfigsLock = new();
/// Every config string passed to , in call order.
public List InitConfigs { get; } = new();
/// Optional per-config init behaviour. When set, it fully owns the init outcome for that
/// config (await/throw); is ignored. Null ⇒ legacy behaviour.
public Func? InitBehavior { get; set; }
/// Gets the driver instance ID.
public string DriverInstanceId => "stub-driver-1";
/// Gets the driver type.
public string DriverType => "Stub";
/// Initializes the driver with the specified configuration JSON.
/// The driver configuration JSON.
/// Cancellation token for the operation.
public virtual async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref InitializeCount);
lock (_initConfigsLock) InitConfigs.Add(driverConfigJson);
if (InitBehavior is not null) { await InitBehavior(driverConfigJson); return; }
if (InitializeShouldThrow) throw new InvalidOperationException("stub-init-fail");
}
/// Reinitializes the driver with the specified configuration JSON.
/// The driver configuration JSON.
/// Cancellation token for the operation.
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReinitializeCount);
return Task.CompletedTask;
}
/// Number of calls — a child actor stopping raises it, so a test
/// can observe a driver child being torn down (e.g. by the #516 config-change respawn).
public int ShutdownCount;
/// Shuts down the driver.
/// Cancellation token for the operation.
public virtual Task ShutdownAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ShutdownCount);
return Task.CompletedTask;
}
/// Gets the health status of the driver.
///
/// Virtual so the #527 comms-loss watchdog suite can drive health directly. Note the
/// : this stub is permanently Healthy with an always-fresh
/// LastSuccessfulRead, so the watchdog can never fire for it — which is why every existing
/// test built on is unaffected by the watchdog, and equally why a watchdog
/// test built on it would be vacuous.
///
public virtual DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
}
///
/// A that also implements , recording every
/// write call and returning a configurable status code.
///
internal sealed class WritableStubDriver : StubDriver, IWritable
{
/// Gets or sets the next status code to return from write operations.
public uint NextStatusCode { get; set; } = 0u;
/// Gets the list of write requests received.
public List Writes { get; } = new();
/// Writes the specified requests.
/// The write requests.
/// Cancellation token for the operation.
public Task> WriteAsync(
IReadOnlyList writes, CancellationToken cancellationToken)
{
Writes.AddRange(writes);
IReadOnlyList results = writes.Select(_ => new WriteResult(NextStatusCode)).ToList();
return Task.FromResult(results);
}
}
///
/// A that also implements , firing
/// on demand and counting subscribe calls.
///
internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
{
/// Occurs when data changes.
public event EventHandler? OnDataChange;
private readonly StubHandle _handle = new();
/// Gets the number of subscribers to OnDataChange.
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
/// Number of times was called (re-subscribe asserts).
public int SubscribeCount;
/// The reference set passed to the most recent call.
public IReadOnlyList? LastSubscribedRefs;
/// Number of times was called — the observable for a live
/// subscription being torn down (an EMPTY desired set drops the handle rather than re-subscribing).
public int UnsubscribeCount;
/// When true, genuinely yields (await Task.Yield())
/// before completing, so a ConfigureAwait(false) continuation in the actor resumes off the
/// Akka ActorContext on a thread-pool thread — reproducing the no-ActorContext race that a
/// synchronously-completed stub task hides (the continuation otherwise runs inline).
public bool UnsubscribeYields { get; set; }
///
/// How many of the first calls throw before one succeeds. Default 0
/// (always succeed) leaves existing behaviour untouched.
///
///
/// This is how a test reaches the state the #488 reconcile exists for: the actor is Connected and
/// holds a desired ref set, but the subscribe that should have established the handle failed, so
/// _subscriptionHandle is null and nothing else will ever re-assert it — there is no
/// connect transition coming, and no deploy.
///
public int SubscribeFailuresBeforeSuccess { get; set; }
/// Subscribes to the specified full references.
/// The full references to subscribe to.
/// The publishing interval.
/// Cancellation token for the operation.
public Task SubscribeAsync(
IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var attempt = Interlocked.Increment(ref SubscribeCount);
LastSubscribedRefs = fullReferences;
if (attempt <= SubscribeFailuresBeforeSuccess)
{
throw new InvalidOperationException($"stub subscribe failure #{attempt}");
}
return Task.FromResult(_handle);
}
/// Unsubscribes from the specified subscription handle.
/// The subscription handle.
/// Cancellation token for the operation.
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
Interlocked.Increment(ref UnsubscribeCount);
if (UnsubscribeYields)
{
// Complete the awaited task from a fresh background thread that has NO Akka actor
// cell on it, so the caller's `ConfigureAwait(false)` continuation resumes on a
// clean thread-pool thread where InternalCurrentActorCellKeeper.Current is null —
// a deterministic repro of the real async-backend no-ActorContext race.
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(() => tcs.SetResult());
await tcs.Task.ConfigureAwait(false);
}
}
/// Fires a data change event with the specified parameters.
/// The full reference of the data that changed.
/// The new value.
/// The OPC UA status code.
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
}
}
/// Minimal for use by .
internal sealed class StubHandle : ISubscriptionHandle
{
/// Gets the diagnostic ID of the subscription.
public string DiagnosticId => "stub-sub";
}
///
/// A per-instance recording driver for the restart / re-subscribe suite (03/S16, U6): unlike
/// it carries the spec's
/// ( hard-codes a constant on a non-virtual member), and it counts and records the
/// alarm subscription alongside the value one — the two travel in a single
/// SetDesiredSubscriptions but through two different child code paths, so both need observing.
///
///
///
/// The falsification bar these counters exist for: a restart test must assert a FUNCTION the new
/// incarnation performs — it subscribed, it re-initialised — never that the incarnation exists.
/// Existence is what
/// DriverHostActorReconcileTests.RestartDriver_respawns_the_child_without_an_actor_name_collision
/// asserts, and that test passes against the very defect 03/S16 names.
///
///
/// is the supervision crash seam — see
/// DriverHostActorRestartTests's file remarks for why the obvious alternatives do not work.
///
///
internal sealed class RecordingSubscribableDriver : IDriver, ISubscribable, IAlarmSource
{
private readonly Lock _gate = new();
private readonly StubHandle _handle = new();
private readonly RecordingAlarmHandle _alarmHandle = new();
private int _initializeCount;
private int _shutdownCount;
private int _subscribeCount;
private int _subscribeAlarmsCount;
private IReadOnlyList? _lastSubscribedRefs;
private IReadOnlyList? _lastSubscribedAlarmRefs;
/// Initializes a new recording driver stamped with the spec's identity.
/// The driver instance id from the spec that created it.
/// The driver type from the spec that created it.
public RecordingSubscribableDriver(string driverInstanceId, string driverType)
{
DriverInstanceId = driverInstanceId;
DriverType = driverType;
}
///
public string DriverInstanceId { get; }
///
public string DriverType { get; }
/// Number of calls. A supervision restart re-uses the SAME driver
/// object (the Props closure captured it), so this going 1 to 2 is how the reprovision is observed.
public int InitializeCount => Volatile.Read(ref _initializeCount);
/// Number of calls — a restart's PostStop raises it.
public int ShutdownCount => Volatile.Read(ref _shutdownCount);
/// Number of calls.
public int SubscribeCount => Volatile.Read(ref _subscribeCount);
/// Number of calls.
public int SubscribeAlarmsCount => Volatile.Read(ref _subscribeAlarmsCount);
/// The refs passed to the most recent , or null if never called.
public IReadOnlyList? LastSubscribedRefs
{
get { lock (_gate) return _lastSubscribedRefs; }
}
/// The refs passed to the most recent , or null if never called.
public IReadOnlyList? LastSubscribedAlarmRefs
{
get { lock (_gate) return _lastSubscribedAlarmRefs; }
}
///
/// When set, awaits it instead of completing inline — so the actor
/// stays in Connecting for as long as the test wants. Needed because several defects only
/// bite a child that has NOT yet connected (InitializeRequested is unhandled in
/// Connected, so a test built on an already-connected child passes vacuously).
///
public TaskCompletionSource? InitializeGate { get; set; }
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _initializeCount);
if (InitializeGate is { } gate) await gate.Task.ConfigureAwait(false);
}
///
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
///
public Task ShutdownAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref _shutdownCount);
return Task.CompletedTask;
}
///
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
///
public event EventHandler? OnDataChange;
///
public Task SubscribeAsync(
IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _subscribeCount);
lock (_gate) _lastSubscribedRefs = fullReferences.ToArray();
return Task.FromResult(_handle);
}
///
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
///
public event EventHandler? OnAlarmEvent;
///
public Task SubscribeAlarmsAsync(
IReadOnlyList sourceNodeIds, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _subscribeAlarmsCount);
lock (_gate) _lastSubscribedAlarmRefs = sourceNodeIds.ToArray();
return Task.FromResult(_alarmHandle);
}
///
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
///
public Task AcknowledgeAsync(
IReadOnlyList acknowledgements, CancellationToken cancellationToken) => Task.CompletedTask;
/// Fires a well-formed data change (the ordinary production path).
/// The reference whose value changed.
/// The new value.
public void FireDataChange(string fullRef, object? value) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(
_handle, fullRef, new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow)));
///
/// Raises with a NULL Snapshot — the supervision crash seam.
/// DataChangeEventArgs is a positional record with no null validation, and the actor's
/// OnDataChangeForward dereferences msg.Snapshot unguarded ON THE ACTOR THREAD, so this
/// produces a genuine unhandled exception inside the child, which Akka's default decider turns into a
/// Restart. The fault itself is arbitrary; what is under test is the RECOVERY.
///
/// The reference to name in the malformed event.
public void FireMalformedDataChange(string fullRef) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, null!));
/// Raises ; present so the event is not merely written-to-never-read.
/// The condition id to raise.
/// The transition kind to report.
public void FireAlarm(string conditionId, AlarmTransitionKind kind = AlarmTransitionKind.Unspecified) =>
OnAlarmEvent?.Invoke(this, new AlarmEventArgs(
_alarmHandle, conditionId, conditionId, "AlarmCondition", "test", AlarmSeverity.High, DateTime.UtcNow,
Kind: kind));
/// Live subscribers on — an alarm subscription with no handler
/// attached delivers nothing (#662), so this is the other half of "the feed is up".
public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0;
private sealed class RecordingAlarmHandle : IAlarmSubscriptionHandle
{
///
public string DiagnosticId => "recording-alarm-sub";
}
}
///
/// An that hands out s and KEEPS
/// every one it created, in creation order. The list — not a single "the driver" reference — is the point:
/// an operator Restart tears the child down and spawns a new one around a NEW driver object, so the
/// assertion subject is Created[1], while a SUPERVISION restart re-uses Created[0] (the Props
/// closure captured it) and part of the assertion is that Created.Count stayed 1.
///
internal sealed class RecordingSubscribableDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly Lock _gate = new();
private readonly List _created = new();
private readonly bool _gateInitialize;
/// Initializes a factory that materialises exactly one driver type.
/// The driver type this factory answers for.
/// When true, every created driver gets an un-completed
/// , so its child actor stays in
/// Connecting until the test completes it.
public RecordingSubscribableDriverFactory(string supportedType, bool gateInitialize = false)
{
_supportedType = supportedType;
_gateInitialize = gateInitialize;
}
/// Every driver this factory created, in creation order.
public IReadOnlyList Created
{
get { lock (_gate) return _created.ToArray(); }
}
///
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
var driver = new RecordingSubscribableDriver(driverInstanceId, driverType);
if (_gateInitialize)
driver.InitializeGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
lock (_gate) _created.Add(driver);
return driver;
}
///
public IReadOnlyCollection SupportedTypes => new[] { _supportedType };
}