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