using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Covers the consumer (Gitea #518). Nine drivers raise /// when they observe that a remote's tag set may have /// changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug /// rebirth. Before this wiring NOTHING in src/ subscribed, so every one of those raises went into /// the void. /// The signal is advisory. It does not rebuild the served address space: v3 authors raw tags /// explicitly through the /raw browse-commit flow, so a runtime graft would create nodes no /// operator approved. It rides the driver-health snapshot to the AdminUI as a re-browse prompt. /// [Trait("Category", "Unit")] public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTestBase { /// /// The load-bearing case: a driver raises rediscovery while otherwise perfectly healthy, and the /// signal reaches the health publisher carrying the driver's reason. /// [Fact] public void Rediscovery_raise_reaches_the_health_publisher_with_its_reason() { var driver = new RediscoverableStubDriver(); var publisher = new RecordingHealthPublisher(); var parent = CreateTestProbe(); parent.IgnoreMessages(_ => true); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); driver.RaiseRediscovery("deploy-time-changed"); AwaitAssert( () => { var withSignal = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null); withSignal.ShouldNotBeNull(); withSignal!.RediscoveryReason.ShouldBe("deploy-time-changed"); }, TimeSpan.FromSeconds(3)); } /// /// Regression guard for the dedup trap. PublishHealthSnapshot suppresses a publish whose /// (state, lastSuccessfulRead, lastError, errorCount) tuple repeats. A rediscovery raise on an /// otherwise-unchanged Healthy driver leaves all four identical, so unless the rediscovery timestamp /// is part of the fingerprint the dedup swallows the very publish carrying the signal and the operator /// never sees the prompt. /// Positive control: the assertion is that the count STRICTLY INCREASES across the raise. An /// "eventually non-null" assertion alone would pass on the warm-up publish and prove nothing. Revert /// _rediscoveryNeededUtc out of the fingerprint tuple and this test must fail. /// [Fact] public void Rediscovery_raise_is_not_swallowed_by_the_unchanged_health_dedup() { var driver = new RediscoverableStubDriver(); var publisher = new RecordingHealthPublisher(); var parent = CreateTestProbe(); parent.IgnoreMessages(_ => true); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); // Let the actor settle so nothing else is in flight, then capture the baseline. Everything about // the driver's health is now stable — only the rediscovery flag will change. ExpectNoMsg(TimeSpan.FromMilliseconds(200)); var before = publisher.Published.Count; driver.RaiseRediscovery("symbol-version-changed"); AwaitAssert( () => publisher.Published.Count.ShouldBeGreaterThan(before), TimeSpan.FromSeconds(3)); publisher.Published[^1].RediscoveryNeededUtc.ShouldNotBeNull(); } /// /// The signal is sticky: a later health publish still carries it. The remote's tag set stayed /// changed, and only an operator re-browsing resolves that — which this actor cannot observe. A /// flag that cleared itself on the next heartbeat would be a prompt the operator could miss entirely. /// [Fact] public void Rediscovery_flag_persists_on_subsequent_health_publishes() { var driver = new RediscoverableStubDriver(); var publisher = new RecordingHealthPublisher(); var parent = CreateTestProbe(); parent.IgnoreMessages(_ => true); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); driver.RaiseRediscovery("agent-instance-changed"); AwaitAssert( () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null), TimeSpan.FromSeconds(3)); // Force a further publish by changing something else about the driver's health. driver.SetLastError("transient read failure"); AwaitAssert( () => publisher.Published.Any(p => p.LastError == "transient read failure"), TimeSpan.FromSeconds(3)); var latest = publisher.Published[^1]; latest.RediscoveryNeededUtc.ShouldNotBeNull(); latest.RediscoveryReason.ShouldBe("agent-instance-changed"); } /// /// Leak guard. The instance can OUTLIVE the actor — the host stops a /// child and respawns another around the same driver object — so a missing -= in /// PostStop accumulates one handler per respawn, each holding a dead Self. Asserts the /// driver's invocation list is empty again after the actor stops. /// [Fact] public void Stopping_the_actor_detaches_the_rediscovery_handler() { var driver = new RediscoverableStubDriver(); var parent = CreateTestProbe(); parent.IgnoreMessages(_ => true); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => driver.SubscriberCount.ShouldBe(1), TimeSpan.FromSeconds(3)); Watch(actor); actor.Tell(PoisonPill.Instance); ExpectTerminated(actor, TimeSpan.FromSeconds(3)); driver.SubscriberCount.ShouldBe(0); } /// A driver that does NOT implement must publish a null flag — /// the field is absent, not defaulted to "needs re-browse". [Fact] public void A_non_rediscoverable_driver_never_sets_the_flag() { var publisher = new RecordingHealthPublisher(); var parent = CreateTestProbe(); parent.IgnoreMessages(_ => true); var actor = parent.ChildActorOf(DriverInstanceActor.Props(new StubDriver(), healthPublisher: publisher)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); } /// Captures every health publish so a test can assert on the rediscovery fields. private sealed record HealthPublish( string ClusterId, string DriverInstanceId, DriverHealth Health, int ErrorCount5Min, DateTime? RediscoveryNeededUtc, string? RediscoveryReason) { public string? LastError => Health.LastError; } private sealed class RecordingHealthPublisher : IDriverHealthPublisher { private readonly List _published = []; /// Thread-safe snapshot — Publish is called from the actor thread while the test /// asserts from its own. public IReadOnlyList Published { get { lock (_published) return _published.ToArray(); } } /// public void Publish( string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min, DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null) { lock (_published) { _published.Add(new HealthPublish( clusterId, driverInstanceId, health, errorCount5Min, rediscoveryNeededUtc, rediscoveryReason)); } } } /// /// A stub driver exposing , a hook to raise it, and the live subscriber /// count (for the detach guard). /// Implemented from scratch rather than derived from the shared StubDriver on /// purpose. That one returns GetHealth() => new(Healthy, DateTime.UtcNow, null), so its /// health fingerprint differs on EVERY call and the dedup under test never engages — which would make /// pass vacuously /// whether or not the fix is present. This stub's health is STABLE until a test changes it. /// private sealed class RediscoverableStubDriver : IDriver, IRediscoverable { private static readonly DateTime FixedLastRead = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc); private volatile string? _lastError; /// public event EventHandler? OnRediscoveryNeeded; /// public string DriverInstanceId => "rediscoverable-stub-1"; /// public string DriverType => "Stub"; /// Number of live subscribers on . public int SubscriberCount => OnRediscoveryNeeded?.GetInvocationList().Length ?? 0; /// Raises the event exactly as a real driver's watcher does. public void RaiseRediscovery(string reason) => OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, ScopeHint: null)); /// Changes the reported health so a test can force a publish that is NOT the rediscovery one. public void SetLastError(string error) => _lastError = error; /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError); /// public long GetMemoryFootprint() => 0; /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } }