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 #640). A diagnostic fact (S7's negotiated /// PDU size) moves no other fingerprint component, so unless its digest is in /// PublishHealthSnapshot's fingerprint the dedup swallows the very publish that carries it. /// The stub's health is deliberately STABLE: the shared StubDriver.GetHealth() returns /// UtcNow, so the dedup never engages and a test on it passes vacuously. /// [Trait("Category", "Unit")] public sealed class DriverInstanceActorDiagnosticsTests : RuntimeActorTestBase { /// Base case: the bag reaches the publisher. [Fact] public void Diagnostics_reach_the_health_publisher() { var driver = new DiagStubDriver(); driver.Set(("S7.NegotiatedPduSize", "480")); var publisher = new RecordingHealthPublisher(); var actor = SpawnDriverActor(driver, publisher); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => { var latest = publisher.Published.LastOrDefault(); latest.ShouldNotBeNull(); latest!["S7.NegotiatedPduSize"].ShouldBe("480"); }, TimeSpan.FromSeconds(3)); } /// /// The load-bearing case. Falsifiability: publish count must STRICTLY increase across the change; /// drop DiagnosticsDigest from the fingerprint and this goes red. /// [Fact] public void A_diagnostics_change_republishes_despite_otherwise_unchanged_health() { var driver = new DiagStubDriver(); driver.Set(("S7.NegotiatedPduSize", "240")); var publisher = new RecordingHealthPublisher(); var actor = SpawnDriverActor(driver, publisher); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); ExpectNoMsg(TimeSpan.FromMilliseconds(200)); var before = publisher.Published.Count; driver.Set(("S7.NegotiatedPduSize", "480")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(before), TimeSpan.FromSeconds(3)); publisher.Published[^1]!["S7.NegotiatedPduSize"].ShouldBe("480"); } /// Equal-but-distinct bags must be deduped: the digest compares by VALUE, not reference. [Fact] public void An_unchanged_diagnostics_bag_does_not_defeat_the_dedup() { var driver = new DiagStubDriver(); driver.Set(("S7.NegotiatedPduSize", "480")); var publisher = new RecordingHealthPublisher(); var actor = SpawnDriverActor(driver, publisher); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); ExpectNoMsg(TimeSpan.FromMilliseconds(200)); var before = publisher.Published.Count; driver.Set(("S7.NegotiatedPduSize", "480")); ExpectNoMsg(TimeSpan.FromMilliseconds(600)); publisher.Published.Count.ShouldBe(before); } /// A driver without the capability publishes null, not an empty bag. [Fact] public void A_driver_without_the_capability_publishes_null() { var publisher = new RecordingHealthPublisher(); var actor = SpawnDriverActor(new StubDriver(), publisher); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); publisher.Published.ShouldAllBe(p => p == null); } /// A throwing implementation loses only the diagnostics, not the health publish. [Fact] public void A_throwing_diagnostics_provider_degrades_to_null_without_killing_the_health_publish() { var driver = new DiagStubDriver { Throw = true }; var publisher = new RecordingHealthPublisher(); var actor = SpawnDriverActor(driver, publisher); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3)); publisher.Published[^1].ShouldBeNull(); } /// The bag is capped and truncated before it is published. [Fact] public void Oversized_bags_are_bounded() { var driver = new DiagStubDriver(); driver.Set([.. Enumerable.Range(0, 40).Select(i => ($"k{i:D2}", new string('x', 500)))]); var publisher = new RecordingHealthPublisher(); var actor = SpawnDriverActor(driver, publisher); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitAssert(() => { var latest = publisher.Published.LastOrDefault(); latest.ShouldNotBeNull(); latest!.Count.ShouldBe(DriverDiagnostics.MaxEntries); latest.Values.ShouldAllBe(v => v.Length == DriverDiagnostics.MaxValueLength); }, TimeSpan.FromSeconds(3)); } private IActorRef SpawnDriverActor(IDriver driver, IDriverHealthPublisher publisher) { var parent = CreateTestProbe(); parent.IgnoreMessages(_ => true); return parent.ChildActorOf(DriverInstanceActor.Props( driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(50))); } private sealed class RecordingHealthPublisher : IDriverHealthPublisher { private readonly List?> _published = []; 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, IReadOnlyList? hostStatuses = null, SkippedTagReport? skippedTags = null, IReadOnlyDictionary? diagnostics = null) { lock (_published) _published.Add(diagnostics); } } /// Stable-health driver exposing . private sealed class DiagStubDriver : IDriver, IDriverDiagnostics { private static readonly DateTime FixedLastRead = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc); private volatile IReadOnlyDictionary _bag = new Dictionary(); public bool Throw { get; init; } public string DriverInstanceId => "diag-stub-1"; public string DriverType => "Stub"; public void Set(params (string Key, string Value)[] items) => _bag = items.ToDictionary(i => i.Key, i => i.Value); public IReadOnlyDictionary GetDiagnostics() => Throw ? throw new InvalidOperationException("broken") : _bag; 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, null); } }