diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorFreshnessTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorFreshnessTests.cs index 347279c6..70dde5d2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorFreshnessTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorFreshnessTests.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using Akka.Actor; +using Akka.TestKit; +using Akka.TestKit.Xunit2; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; @@ -134,6 +136,82 @@ public sealed class OpcUaPublishActorFreshnessTests : RuntimeActorTestBase AwaitAssert(() => publisher.Levels.Last().ShouldBe((byte)110), PresenceBudget); } + [Fact] + public void Missing_local_snapshot_entry_retries_zero_on_timer_and_recovers() + { + var publisher = new RetryZeroPublisher(); + var clock = new ControlledClock(); + var local = NodeId.Parse("missing-entry-node"); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + serviceLevel: publisher, localNode: local, dbLess: true, + healthTickInterval: TimeSpan.FromMilliseconds(25), utcNow: clock.Read)); + actor.Tell(Snapshot(local, false, clock.Now)); + AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), PresenceBudget); + + // Only this snapshot Tell requests the first zero. Fail it and the next attempt so + // subsequent zero attempts must come from the periodic timer, exercising the null-entry + // branch repeatedly rather than mistaking the Detached branch for missing-node coverage. + actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now)); + AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0 }), PresenceBudget); + publisher.ZeroAttempts.ShouldBeGreaterThanOrEqualTo(3); + + actor.Tell(Snapshot(local, false, clock.Now)); + AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240 }), PresenceBudget); + clock.Advance(TimeSpan.FromSeconds(30) + TimeSpan.FromTicks(1)); + AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 0, 240, 200 }), PresenceBudget); + } + + [Fact] + public void No_snapshot_startup_does_not_publish_across_virtual_timer_periods() + { + // This actor requires the Cluster extension, but the absent/missing-entry guards do + // not inspect membership. Do not self-join: the ordinary harness waits for Up in its + // constructor, which cannot progress with a frozen scheduler. + using var local = new TestKit(AkkaTestHocon + @" + akka.scheduler.implementation = ""Akka.TestKit.TestScheduler, Akka.TestKit"""); + var scheduler = (TestScheduler)local.Sys.Scheduler; + var publisher = new RetryZeroPublisher(); + var clock = new ControlledClock(); + var actor = local.ActorOfAsTestActorRef(OpcUaPublishActor.PropsForTests( + serviceLevel: publisher, localNode: NodeId.Parse("no-snapshot-node"), dbLess: true, + healthTickInterval: TimeSpan.FromSeconds(1), utcNow: clock.Read)); + + // TestActorRef uses the calling-thread dispatcher: Advance completes delivery before + // these assertions, including the immediate startup tick and three periodic ticks. + for (var period = 0; period < 3; period++) + scheduler.Advance(TimeSpan.FromSeconds(1)); + publisher.Levels.ShouldBeEmpty(); + publisher.ZeroAttempts.ShouldBe(0); + clock.Reads.ShouldBe(0); + + // Positive control: the same registered timer must subsequently retry a failed zero. + // No whole snapshot at startup means no publication; a received snapshot missing the + // local entry means zero. Keep these two policies explicitly distinct. + actor.Tell(Snapshot(NodeId.Parse("other-node"), false, clock.Now)); + publisher.ZeroAttempts.ShouldBe(1); + scheduler.Advance(TimeSpan.FromSeconds(1)); + publisher.ZeroAttempts.ShouldBe(2); + publisher.Levels.ShouldBeEmpty(); + scheduler.Advance(TimeSpan.FromSeconds(1)); + publisher.ZeroAttempts.ShouldBe(3); + publisher.Levels.ShouldBe(new byte[] { 0 }); + clock.Reads.ShouldBe(0); + } + + private sealed class RetryZeroPublisher : IServiceLevelPublisher + { + private readonly ConcurrentQueue _levels = new(); + private int _zeroAttempts; + public byte[] Levels => _levels.ToArray(); + public int ZeroAttempts => Volatile.Read(ref _zeroAttempts); + public void Publish(byte serviceLevel) + { + if (serviceLevel == 0 && Interlocked.Increment(ref _zeroAttempts) <= 2) + throw new InvalidOperationException("test zero-publication failure"); + _levels.Enqueue(serviceLevel); + } + } + private sealed class ControlledClock { private long _ticks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks;