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