diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index e5ac2a8c..baf11d1a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -55,11 +55,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); - var asker = CreateTestProbe(); // The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); + RouteWriteUntilAccepted(actor, $"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns); - asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => { recorder.Writes.Count.ShouldBe(1); @@ -79,10 +77,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); - var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref); + RouteWriteUntilAccepted(actor, $"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw); - asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => { recorder.Writes.Count.ShouldBe(1); @@ -91,6 +87,48 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase }, duration: Timeout); } + /// + /// Routes a write, retrying until the host accepts it, and fails with the host's own rejection + /// Reason if it never does. + /// + /// + /// Why a retry rather than a single Tell. ApplyAck — which + /// waits for — marks the end of the apply, not the point at + /// which a write can succeed. Several things still have to happen after it: the spawned child has to + /// finish InitializeAsync and leave Connecting (that state deliberately fast-fails + /// writes with "driver not connected"), and the host has to push the NodeId→driver reverse map that + /// resolves the write. Telling the write immediately therefore races the setup, which is why this + /// assertion failed roughly 1 run in 30 of the fully parallel assembly — and never once in 60 + /// consecutive runs of this class alone. + /// Why retrying does not weaken the assertion. Every rejection branch — the Primary + /// gate, an unresolved reverse map, "driver not running", and the child's own pre-Connected + /// fast-fail — replies without reaching the driver. A rejected attempt records nothing, so + /// the caller's Writes.Count.ShouldBe(1) still means exactly what it did before: the accepted + /// write reached the driver exactly once. + /// The failure message carries the last Reason so a genuine regression is diagnosable + /// rather than surfacing as a bare "expected True". + /// + /// The driver-host actor. + /// The ns-qualified NodeId string, as the node manager passes it. + /// The value to write. + /// The address-space realm the NodeId belongs to. + private void RouteWriteUntilAccepted( + IActorRef actor, string nodeId, object value, AddressSpaceRealm realm) + { + var lastReason = "(no reply)"; + AwaitAssert( + () => + { + var asker = CreateTestProbe(); + actor.Tell(new DriverHostActor.RouteNodeWrite(nodeId, value, realm), asker.Ref); + var result = asker.ExpectMsg(TimeSpan.FromSeconds(1)); + lastReason = result.Reason ?? "(none)"; + result.Success.ShouldBeTrue($"host kept rejecting the write; last reason: {lastReason}"); + }, + duration: Timeout, + interval: TimeSpan.FromMilliseconds(50)); + } + /// On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver /// receives NO write — the primary gate fires before the reverse-map lookup. [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs index f2fb1f81..be6fd400 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/RuntimeActorTestBase.cs @@ -11,6 +11,25 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; /// public abstract class RuntimeActorTestBase : TestKit { + /// + /// Shared upper bound for a presence wait (AwaitAssert / AwaitCondition). + /// + /// + /// A budget, not a delay. These helpers poll and return the instant the condition holds, + /// so the value is how long to wait before giving up. Raising it costs nothing on the happy path and + /// cannot make a genuinely failing assertion pass — it only changes how quickly a real + /// breakage is reported. That is the opposite of an absence window (ExpectNoMsg, + /// settle-then-assert), where the elapsed time IS the assertion and every millisecond is spent on + /// every run. Absence windows keep their own short, individually-calibrated literals and must not be + /// routed through here. + /// Why it exists (Gitea #500). This assembly runs 44 Akka test classes roughly 14-way + /// parallel, each with its own ActorSystem. Budgets of 300–500 ms sized on an + /// idle machine are comfortable in isolation and marginal under that contention: three consecutive + /// 30-run verification rounds each surfaced a different test failing on one, in three + /// different files. Fixing them one at a time was chasing a distribution rather than a defect. + /// + protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15); + /// Gets the Akka test HOCON configuration string for single-node cluster setup. protected static string AkkaTestHocon => @" akka { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs index 4acd8e79..5937cd3e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs @@ -57,7 +57,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Adm, RedundancyRole.Detached))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies the child for a departed peer is stopped when the next snapshot omits it. @@ -71,11 +71,11 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies a single-node snapshot (just the local node) spawns no children. @@ -88,7 +88,7 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies a previously-removed peer is respawned when it re-appears, without an @@ -103,17 +103,17 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Locks in the stale-Terminated guard: when an OLD (already-replaced) child's @@ -132,27 +132,27 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); - AwaitAssert(() => spawned.Count.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); + AwaitAssert(() => spawned.Count.ShouldBe(1), duration: PresenceBudget); var oldRef = spawned[0]; // Drop the peer -> child #0 stopped, ChildCount back to 0. sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); // Re-add the SAME peer -> a NEW child #1 (the FRESH ref) is spawned. sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); - AwaitAssert(() => spawned.Count.ShouldBe(2), duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); + AwaitAssert(() => spawned.Count.ShouldBe(2), duration: PresenceBudget); // Now deliver a STALE Terminated for the OLD ref. The current child for Peer is the fresh // child #1, so ref-equality finds no match and the supervisor must leave ChildCount at 1. sup.Tell(new Terminated(oldRef, existenceConfirmed: true, addressTerminated: false)); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs index 0a32804e..b665e221 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs @@ -295,10 +295,24 @@ public sealed class ContinuousHistorizationRecorderTests : TestKit // The first drain returns false (entry retained); after the backoff the retry drain succeeds // and acks, truncating the outbox to 0. - await AwaitAssertAsync(async () => - Assert.Equal(0, await outbox.CountAsync(default)), TimeSpan.FromSeconds(5)); - - Assert.True(writer.CallCount >= 2, "the writer must have been called at least twice (a retry happened)"); + // + // BOTH conditions are polled together, and the retry count is the load-bearing one. An empty + // outbox is NOT a distinguishing observation: it is equally true BEFORE the append lands, so + // waiting on it alone can be satisfied by the initial state and return before the recorder has + // done anything at all. That is exactly what happened intermittently under a fully parallel + // assembly run — the poll won the race against the first append, the wait returned immediately, + // and the CallCount check that used to sit outside the block then failed against a recorder that + // had not yet run. (The same trap is called out on the preceding test, which guards against it by + // pairing its count with a writer observation.) + await AwaitAssertAsync( + async () => + { + Assert.True( + writer.CallCount >= 2, + "the writer must have been called at least twice (a retry happened)"); + Assert.Equal(0, await outbox.CountAsync(default)); + }, + TimeSpan.FromSeconds(5)); } [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs index 4167c31d..d58ff74d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/HistorianAdapterActorTests.cs @@ -23,9 +23,27 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase /// The local node id the gating tests construct the adapter with. private static readonly NodeId LocalNode = new("node-A"); - /// A short window we allow the fire-and-forget enqueue to land within. + /// + /// The window an absence assertion waits before concluding nothing arrived + /// (ExpectNoMsg). Its length is a calibration decision — long enough that a message which + /// was going to arrive would have — so it is deliberately NOT generous. + /// private static readonly TimeSpan Settle = TimeSpan.FromMilliseconds(500); + /// + /// The budget a presence assertion may take to become true (AwaitAssert). + /// + /// + /// Separate from on purpose, though they once shared its 500 ms. The two are + /// different quantities that merely had the same number: a presence budget is an upper bound before + /// giving up, and AwaitAssert returns the instant the condition holds, so a generous value + /// costs nothing in the passing case and can never make a genuinely failing assertion pass. An + /// absence window is the opposite — every millisecond is spent on every run. Conflating them meant + /// the enqueue assertions could only be given more headroom by slowing every ExpectNoMsg in + /// the class, so they kept a 500 ms budget that a fully parallel assembly run occasionally missed. + /// + private static readonly TimeSpan AssertTimeout = TimeSpan.FromSeconds(5); + /// Thread-safe fake sink that records every call. private sealed class RecordingSink : IAlarmHistorianSink { @@ -101,7 +119,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase actor.Tell(SampleEvent()); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Secondary suppression (T2): when the cached local role is Secondary, the adapter MUST @@ -145,7 +163,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase TellRedundancyRole(actor, RedundancyRole.Primary); actor.Tell(SampleEvent()); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Absent-node default-historize (T5): a snapshot that mentions only a DIFFERENT node @@ -174,7 +192,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase actor.Tell(SampleEvent()); // Local role is still unknown ⇒ default-historize path: sink must record exactly one enqueue. - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Builds an (the shape published on the alerts @@ -222,7 +240,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase e.Severity.ShouldBe(AlarmSeverity.High); e.Comment.ShouldBe("note"); }, - Settle); + AssertTimeout); } /// Secondary suppression for alerts (T7): a Secondary node must NOT historize a transition @@ -252,7 +270,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase TellRedundancyRole(actor, RedundancyRole.Primary); actor.Tell(SampleTransition()); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Per-alarm opt-out (T8b): a Primary node must NOT historize a transition whose @@ -286,7 +304,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase TellRedundancyRole(actor, RedundancyRole.Primary); actor.Tell(SampleTransition(historizeToAveva: null)); - AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle); + AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), AssertTimeout); } /// Severity buckets (T9): the OPC UA 1–1000 numeric severity on the transition maps onto @@ -308,7 +326,7 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase AwaitAssert( () => sink.Events.ShouldHaveSingleItem().Severity.ShouldBe(expected), - Settle); + AssertTimeout); } /// Rolling-restart null default (T10): an old-format transition deserialized by Akka's JSON @@ -326,6 +344,6 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase AwaitAssert( () => sink.Events.ShouldHaveSingleItem().AlarmTypeName.ShouldBe("AlarmCondition"), - Settle); + AssertTimeout); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 33b4153c..4bd67911 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -48,11 +48,16 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + // A PRESENCE budget, so it is an upper bound before giving up rather than a wait: the helper polls + // and returns the instant the meter fires, so a generous value costs nothing on the happy path and + // can never make a genuinely failing assertion pass. Two seconds covered an idle machine but not a + // fully parallel assembly run, where this deploy→rebuild→throw chain (two DB-backed applies) has to + // share 14 cores with 40-odd other Akka test classes; it failed there roughly 1 run in 30. AwaitAssert(() => { recorder.Total.ShouldBeGreaterThanOrEqualTo(1); recorder.WithTag("kind", "rebuild").ShouldBeGreaterThanOrEqualTo(1); - }, duration: TimeSpan.FromSeconds(2)); + }, duration: TimeSpan.FromSeconds(15)); } /// A clean rebuild does NOT increment the apply-failed meter (Info-only happy path). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index a51410b4..8a8c364f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -45,7 +45,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase sink.Calls.ShouldContain("EF:eq-1"); sink.Calls.ShouldContain("EF:eq-2"); sink.Calls.ShouldContain("NA:line-1"); - }, duration: TimeSpan.FromSeconds(2)); + }, duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); } @@ -85,7 +85,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild var callsAfterFirst = sink.Calls.Count; @@ -107,7 +107,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } /// @@ -132,7 +132,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact)); // The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised… - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); // …and the address-space-wiping raw-sink fallback was NOT taken. sink.RebuildCalls.ShouldBe(0); } @@ -176,7 +176,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } /// @@ -197,7 +197,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); } @@ -230,7 +230,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice. // t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1". - AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: PresenceBudget); sinkA.RebuildCalls.ShouldBe(0); // t-main (MAIN cluster) must NOT leak onto the SITE-A node. sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1"); @@ -249,7 +249,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: PresenceBudget); sinkM.RebuildCalls.ShouldBe(0); sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1"); } @@ -331,7 +331,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); var calls = sink.Calls.ToList(); @@ -356,7 +356,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:")); @@ -365,7 +365,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); // No new NodeAdded announcement was raised for the rebuild-kind deploy. sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(naAfterFirst); } @@ -388,7 +388,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); var callsAfterFirst = sink.Calls.Count; // The next deploy arrives while the ConfigDb is briefly unreachable. @@ -423,13 +423,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); // eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); - AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: PresenceBudget); } /// An whose CreateDbContext can be made to @@ -552,7 +552,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd // (area + line + equipment all added), so NO full rebuild; the folder is materialised directly. actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); - AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: TimeSpan.FromSeconds(2)); + AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and @@ -563,7 +563,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase AwaitAssert(() => { sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South")); - }, duration: TimeSpan.FromSeconds(2)); + }, duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // the rename did NOT force a full rebuild } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index e10e9329..a6f10ef3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -53,7 +53,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase sink.Values[0].Value.ShouldBe(3.14); sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good); sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot. @@ -73,7 +73,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase sink.Alarms[0].State.Active.ShouldBeTrue(); sink.Alarms[0].State.Acknowledged.ShouldBeFalse(); sink.Alarms[0].State.Severity.ShouldBe((ushort)700); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// #477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm. @@ -93,7 +93,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi"); q.Quality.ShouldBe(OpcUaQuality.Bad); q.Realm.ShouldBe(AddressSpaceRealm.Raw); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// Builds a test with sensible defaults so each test @@ -117,7 +117,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); - AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); + AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } /// Verifies that forwards to the @@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes")); sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false)); sink.ModelChanges.ShouldContain("EQ-1"); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); } /// Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level. @@ -161,7 +161,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100)); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the very first computed ServiceLevel is always published even when it is @@ -186,7 +186,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader. @@ -210,7 +210,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(snapshot); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that RedundancyStateChanged for secondary publishes 100. @@ -231,7 +231,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase actor.Tell(snapshot); AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 250 for a healthy primary role-leader @@ -256,7 +256,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 240 for a healthy non-leader secondary @@ -282,7 +282,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 100 when the DB is unreachable @@ -307,7 +307,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)100), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the calculator path computes 200 for a stale snapshot when the DB is @@ -335,7 +335,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that a detached local node publishes 0 (the calculator does not model @@ -362,7 +362,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); // Now detach — expect the guard to drive ServiceLevel down to 0. actor.Tell(new RedundancyStateChanged( @@ -374,7 +374,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that an actively-observed, recent peer probe of MY endpoint that came back @@ -403,7 +403,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies branch (3) of OpcUaProbeOk(): a peer's NEGATIVE verdict about this node @@ -447,7 +447,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase // Healthy (250), NOT 0 — proves an aged negative verdict does not demote. AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that with no peer probe result ever received, OpcUaProbeOk() defaults @@ -474,7 +474,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that a later Ok==true peer probe supersedes an earlier Ok==false @@ -503,7 +503,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that a peer probe result about a DIFFERENT node is ignored — it does not @@ -531,7 +531,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies the legacy back-compat seam: with no DB-health probe wired, the handler @@ -553,7 +553,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Verifies that the periodic HealthTick Asks the local @@ -607,7 +607,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus). @@ -629,7 +629,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness @@ -654,7 +654,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)200), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less @@ -677,7 +677,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase }, CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); actor.Tell(new RedundancyStateChanged( Nodes: new[] @@ -688,7 +688,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)0), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default @@ -714,7 +714,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase CorrelationId.NewId())); AwaitAssert(() => publisher.Levels.ShouldContain((byte)240), - duration: TimeSpan.FromMilliseconds(500)); + duration: PresenceBudget); } /// Stub DB-health probe actor that answers diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 86686e86..27861788 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -28,7 +28,23 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; /// public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase { - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8); + /// + /// Upper bound for the presence waits in this class (ExpectMsg / FishForMessage). + /// + /// + /// Sized for a real Roslyn compilation, not for message passing. + /// ApplyScriptedAlarms drives ScriptedAlarmEngine.LoadAsync, which compiles every + /// alarm predicate through ScriptEvaluator.Compile; the RegisterInterest these tests + /// wait on is only sent once that finishes. A cold C# script compile is slow and highly variable — + /// far more so than anything else in this assembly — and Roslyn's caches are per-process, so the + /// first class to compile pays the worst of it. Under a fully parallel assembly run, sharing 14 + /// cores with 40-odd other Akka test classes, 8 s was occasionally missed (Gitea #500). + /// Raising it is free on the happy path: these waits return the instant the message arrives, + /// so the value is an upper bound before giving up, not a delay, and it cannot make a genuinely + /// failing expectation pass. The absence assertions in this class deliberately keep their own + /// short literals — there the elapsed time is the point. + /// + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); /// Plan whose predicate compares the single tag "M.T" against 90 — enabled by default. private static EquipmentScriptedAlarmPlan Plan( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs index a63d9ed5..38fb64bd 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs @@ -79,7 +79,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase entry.Message.ShouldContain("syntax error"); entry.ScriptId.ShouldBe("script-7"); entry.VirtualTagId.ShouldBe("vt-1"); - }, duration: TimeSpan.FromMilliseconds(500)); + }, duration: PresenceBudget); // 02/S13: the failure now ALSO degrades the node — with no declared dependencyRefs the // inputs-ready gate is vacuously satisfied, so a Bad EvaluationResult reaches the parent (this diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index d74f7276..e61de11a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -260,7 +260,15 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase // The old child is stopped (PostStop ⇒ UnregisterInterest) and a new one spawned // (PreStart ⇒ RegisterInterest on "B"). Both messages arrive at the mux probe; order between // the dying child's PostStop and the new child's PreStart is not guaranteed, so accept either. + // + // Because the order is genuinely undefined, the SENDER of the RegisterInterest must be captured + // as that message is received. Reading mux.LastSender after the loop instead would read the + // sender of whichever message happened to arrive SECOND — the dying child when the interleaving + // is [Register, Unregister] — so the identity assertion below silently depended on the very + // ordering this loop exists to tolerate. That made it fail roughly 10% of the time under a + // fully parallel assembly run, and never in isolation. DependencyMuxActor.RegisterInterest? reg2 = null; + IActorRef? secondChild = null; var sawUnregister = false; for (var i = 0; i < 2; i++) { @@ -269,6 +277,7 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase { case DependencyMuxActor.RegisterInterest r: reg2 = r; + secondChild = mux.LastSender; // sender OF THIS message, not of the last one seen break; case DependencyMuxActor.UnregisterInterest: sawUnregister = true; @@ -282,7 +291,8 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase reg2.TagRefs.ShouldNotContain("A"); // The replacement is a different actor ref than the original (auto-named, so no collision). - mux.LastSender.ShouldNotBe(firstChild); + secondChild.ShouldNotBeNull(); + secondChild.ShouldNotBe(firstChild); } ///