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; /// /// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the /// remote offers against what an operator authored. /// Why this is gated rather than run for every driver. Modbus, S7, AbLegacy, Sql and Mqtt /// all stream their AUTHORED tags back out of DiscoverAsync rather than enumerating the device, so /// diffing the two sets for them is a tautology that reports "no drift" forever. A check that cannot fail /// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that /// declare SupportsOnlineDiscovery AND report AuthoredDiscoveryRefs. /// The signal is advisory and shares the Stage-2 surfacing: it raises the same /hosts /// re-browse prompt an raise does. The served address space is never /// rebuilt — raw tags are authored through the /raw browse-commit flow. /// [Trait("Category", "Unit")] public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase { private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60); private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3); /// A device offering a tag nothing binds raises the operator prompt, naming what appeared. [Fact] public void A_new_ref_on_the_device_raises_the_re_browse_prompt() { var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]); var publisher = new RecordingHealthPublisher(); var actor = SpawnConnected(driver, publisher); AwaitAssert( () => { var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null); signalled.ShouldNotBeNull(); signalled!.RediscoveryReason.ShouldNotBeNull(); signalled.RediscoveryReason!.ShouldContain("1 new on device"); signalled.RediscoveryReason!.ShouldContain("c"); }, Budget); Sys.Stop(actor); } /// /// The gate that makes this feature honest. A driver whose DiscoverAsync replays /// authored config rather than browsing the device declares SupportsOnlineDiscovery == false, /// and must never be drift-checked — for it the comparison could only ever say "no drift". /// Positive control: the driver is handed a set that WOULD read as drift, and the assertion is /// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong /// reason (e.g. if the timer never fired at all). /// [Fact] public void A_driver_that_replays_authored_config_is_never_drift_checked() { var driver = new BrowsableStubDriver( discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false); var publisher = new RecordingHealthPublisher(); var actor = SpawnConnected(driver, publisher); // Long enough for several ticks had the timer been scheduled. ExpectNoMsg(TimeSpan.FromMilliseconds(400)); driver.DiscoverCount.ShouldBe( 0, "a driver that replays authored config was browsed for drift — the comparison is a tautology " + "for it and would report 'no drift' forever, which reads as coverage"); publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); Sys.Stop(actor); } /// A driver that browses but does NOT report its authored refs opts out — the default. An empty /// collection would mean "nothing authored, everything is new", so null must not be read that way. [Fact] public void A_driver_that_reports_no_authored_refs_is_never_drift_checked() { var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null); var publisher = new RecordingHealthPublisher(); var actor = SpawnConnected(driver, publisher); ExpectNoMsg(TimeSpan.FromMilliseconds(400)); driver.DiscoverCount.ShouldBe(0); publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null); Sys.Stop(actor); } /// /// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp /// keeps moving reads as "it just happened again" rather than "it is still true". /// [Fact] public void An_unchanged_drift_is_reported_once() { var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); var publisher = new RecordingHealthPublisher(); var actor = SpawnConnected(driver, publisher); AwaitAssert( () => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1), Budget); // Several more ticks pass with the same drift. AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget); publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe( 1, "an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp"); Sys.Stop(actor); } /// A failed browse is not drift. A device that is merely unreachable already shows on the health /// surface; reporting drift would turn every comms blip into "all your tags vanished". [Fact] public void A_failed_browse_is_not_reported_as_drift() { var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true }; var publisher = new RecordingHealthPublisher(); var actor = SpawnConnected(driver, publisher); AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget); publisher.Published.ShouldAllBe( p => p.RediscoveryNeededUtc == null, "a browse that threw was treated as drift — an unreachable device would report every authored " + "tag as vanished"); Sys.Stop(actor); } /// Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next /// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one. [Fact] public void Drift_that_resolves_clears_the_prompt() { var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]); var publisher = new RecordingHealthPublisher(); var actor = SpawnConnected(driver, publisher); AwaitAssert( () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null), Budget); // The operator commits the new tag: authored now matches the device. driver.SetAuthored(["a", "b"]); AwaitAssert( () => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(), Budget); Sys.Stop(actor); } private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher) { var parent = CreateTestProbe(); parent.IgnoreMessages(_ => true); var actor = parent.ChildActorOf(DriverInstanceActor.Props( driver, healthPublisher: publisher, driftCheckInterval: Tick)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); return actor; } /// Captured health publishes, so a test can read the rediscovery fields. private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason); 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) { lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason)); } } /// /// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages — /// the shared StubDriver returns DateTime.UtcNow, which would make every publish look /// changed and let a dedup-related bug pass unnoticed. /// private sealed class BrowsableStubDriver( string[] discovered, string[]? authored, bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery { private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc); private volatile string[]? _authored = authored; private int _discoverCount; /// Number of discovery passes the actor has driven — the direct read of whether the drift /// check ran at all. public int DiscoverCount => Volatile.Read(ref _discoverCount); /// When true, every browse throws — models an unreachable device. public bool DiscoverThrows { get; init; } /// public string DriverInstanceId => "browsable-stub-1"; /// public string DriverType => "Stub"; /// public bool SupportsOnlineDiscovery => supportsOnlineDiscovery; /// public IReadOnlyCollection? AuthoredDiscoveryRefs => _authored; /// Models the operator committing (or removing) tags between checks. public void SetAuthored(string[] refs) => _authored = refs; /// public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { Interlocked.Increment(ref _discoverCount); if (DiscoverThrows) throw new IOException("device unreachable"); var folder = builder.Folder("Stub", "Stub"); foreach (var r in discovered) { folder.Variable(r, r, new DriverAttributeInfo( FullName: r, DriverDataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false)); } return Task.CompletedTask; } /// 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); /// public long GetMemoryFootprint() => 0; /// public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; } }