154171f48c
The reported symptom was "Runtime.Tests occasionally reports Failed: 1" with no test
name. Instrumenting 30 full-assembly runs reproduced it at 13% (4 runs) and showed it
was never ONE flaky test: five failures across three distinct tests in that batch, and
five more distinct tests over the verification rounds that followed.
Two hypotheses were tested and DISPROVED by measurement before anything was changed:
- Cluster-formation timeout. Every test in this assembly forms a real single-node Akka
cluster over TCP in RuntimeActorTestBase's constructor and waits 5 s for MemberStatus.Up
— 219 formations per run. Instrumented: idle max 960 ms, under-load max 1470 ms, zero
timeouts. A 3.4x margin; not the cause.
- Ephemeral-port exhaustion from that bind churn. TIME_WAIT measured at ~0 throughout.
Not the cause.
FOUR GENUINE ORDERING/LOGIC DEFECTS (none is a timeout):
1. VirtualTagHostActorTests.ApplyVirtualTags_respawns_child_when_plan_changes_in_place
The test loops to accept RegisterInterest/UnregisterInterest in EITHER order — and
then asserts on mux.LastSender, which DEPENDS on that order. Under the [Register,
Unregister] interleaving LastSender is the DYING child and the assertion fails. Now
captures the sender of the Register message as it arrives. Fully deterministic; no
timing involved. (Swept the other four LastSender sites: all drain the Unregister
first, so their ordering is fixed. Only this one was unsafe.)
2. DriverHostActorWriteRoutingTests.Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath
ApplyAck marks the end of the APPLY, not the point a write can succeed: the child is
still in Connecting, which deliberately fast-fails writes ("driver not connected"),
and the NodeId->driver reverse map has not been pushed. Now retries until accepted.
This does not weaken the assertion — every rejection branch replies WITHOUT reaching
the driver, so Writes.Count.ShouldBe(1) still means exactly what it did.
3. HistorianAdapterActorTests.Redundancy_snapshot_without_local_node_...
One 500 ms constant served both presence budgets (AwaitAssert) and absence windows
(ExpectNoMsg). Different quantities that happened to share a number, so the presence
budget could not be raised without slowing every absence check. Split into
AssertTimeout (5 s) and Settle (500 ms).
4. ContinuousHistorizationRecorderTests.Retry_after_writer_failure_eventually_acks
Waited on `outbox == 0`, which is ALSO true before the first append — so the poll
could win the race against the recorder and return having observed the INITIAL state,
after which the CallCount>=2 check outside the block failed against a recorder that
had not run. Both conditions are now polled together with the retry count as the
discriminator. (The preceding test in the same file documents this exact trap.)
THE PRESENCE-BUDGET CLASS:
After those four, three consecutive 30-run rounds each still failed once — on a
DIFFERENT test, in a DIFFERENT file, every time (OpcUaPublishActorApplyFailureTests 2 s,
OpcUaPublishActorRebuildTests 2 s, OpcUaPublishActorTests 500 ms). That is a
distribution, not a defect, and fixing it one test at a time was the wrong method.
RuntimeActorTestBase now carries a documented PresenceBudget (15 s) and 57 sub-3 s
AwaitAssert/AwaitCondition durations across four files route through it.
Why this is safe rather than sloppy: a presence budget is an upper bound before giving
up, not a wait — these helpers poll and return the instant the condition holds. Raising
one costs nothing on the happy path and CANNOT make a genuinely failing assertion pass;
it only changes how quickly a real breakage reports. Absence windows are the opposite
(the elapsed time IS the assertion) and were deliberately left untouched with their own
short, individually-calibrated literals.
ScriptedAlarmHostActorTests is a special case, diagnosed rather than merely raised: its
8 s budget was sized for message passing but actually waits on a REAL Roslyn compile
(ApplyScriptedAlarms -> ScriptedAlarmEngine.LoadAsync compiles every predicate). Cold
script compiles are the slowest and most variable thing in the assembly, so that class
gets 30 s with the reason recorded.
Verification: 30 consecutive full-assembly runs, 30 passed / 0 failed, against a 13%
baseline. Full solution builds; all 41 unit-test projects green.
NOT fixed here, filed as #501: DriverHostActorNativeAlarmAckRoutingTests:90 and :116 use
AwaitAssert for an ABSENCE assertion, so they return instantly and prove nothing. They
are excluded from the budget change on purpose — a bigger number does not fix them, and
rewriting them to settle-then-assert may legitimately turn them red.
828 lines
42 KiB
C#
828 lines
42 KiB
C#
using System.Collections.Concurrent;
|
|
using Akka.Actor;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
|
|
|
public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|
{
|
|
/// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
|
|
[Fact]
|
|
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
|
|
{
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
|
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
|
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
|
|
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
|
|
/// <summary>Verifies that production props target the OPC UA synchronized dispatcher.</summary>
|
|
[Fact]
|
|
public void Production_Props_targets_opcua_synchronized_dispatcher()
|
|
{
|
|
var props = OpcUaPublishActor.Props();
|
|
props.Dispatcher.ShouldBe(OpcUaPublishActor.DispatcherId);
|
|
}
|
|
|
|
/// <summary>Verifies that AttributeValueUpdate routes to sink WriteValue.</summary>
|
|
[Fact]
|
|
public void AttributeValueUpdate_routes_to_sink_WriteValue()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
|
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
sink.Values.Count.ShouldBe(2);
|
|
sink.Values[0].NodeId.ShouldBe("ns=2;s=T1");
|
|
sink.Values[0].Value.ShouldBe(3.14);
|
|
sink.Values[0].Quality.ShouldBe(OpcUaQuality.Good);
|
|
sink.Values[1].Quality.ShouldBe(OpcUaQuality.Uncertain);
|
|
}, duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmCondition with the full snapshot.</summary>
|
|
[Fact]
|
|
public void AlarmStateUpdate_routes_to_sink_WriteAlarmCondition()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
|
|
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
sink.Alarms.Count.ShouldBe(1);
|
|
sink.Alarms[0].AlarmNodeId.ShouldBe("ns=2;s=A1");
|
|
sink.Alarms[0].State.Active.ShouldBeTrue();
|
|
sink.Alarms[0].State.Acknowledged.ShouldBeFalse();
|
|
sink.Alarms[0].State.Severity.ShouldBe((ushort)700);
|
|
}, duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>#477 — AlarmQualityUpdate routes to sink.WriteAlarmQuality with the quality + realm.</summary>
|
|
[Fact]
|
|
public void AlarmQualityUpdate_routes_to_sink_WriteAlarmQuality()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate(
|
|
"Plant/Modbus/dev1/temp_hi", OpcUaQuality.Bad, DateTime.UtcNow, Realm: AddressSpaceRealm.Raw));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
sink.AlarmQualityQueue.Count.ShouldBe(1);
|
|
sink.AlarmQualityQueue.TryPeek(out var q).ShouldBeTrue();
|
|
q.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
|
|
q.Quality.ShouldBe(OpcUaQuality.Bad);
|
|
q.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
}, duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each test
|
|
/// only specifies the fields it cares about.</summary>
|
|
private static AlarmConditionSnapshot Snapshot(
|
|
bool active = false,
|
|
bool acknowledged = true,
|
|
bool confirmed = true,
|
|
bool enabled = true,
|
|
AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
|
|
ushort severity = 500,
|
|
string message = "test") =>
|
|
new(active, acknowledged, confirmed, enabled, shelving, severity, message);
|
|
|
|
/// <summary>Verifies that RebuildAddressSpace calls sink Rebuild.</summary>
|
|
[Fact]
|
|
public void RebuildAddressSpace_calls_sink_Rebuild()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
|
|
|
|
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> forwards to the
|
|
/// applier, which drives the sink to ensure the discovered folder + (read-only) variable and announce a
|
|
/// NodeAdded model-change under the equipment root — proving the message → handler → applier → sink path
|
|
/// end to end (mirrors the real-applier-over-recording-sink harness in
|
|
/// <c>OpcUaPublishActorRebuildTests</c>).</summary>
|
|
[Fact]
|
|
public void MaterialiseDiscoveredNodes_routes_through_applier_to_sink()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
|
|
|
var folders = new[] { new DiscoveredFolder("EQ-1/Axes", "EQ-1", "Axes") };
|
|
var variables = new[]
|
|
{
|
|
new DiscoveredVariable("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double",
|
|
Writable: false, IsArray: false, ArrayLength: null),
|
|
};
|
|
|
|
actor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
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: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
|
|
[Fact]
|
|
public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(serviceLevel: publisher));
|
|
|
|
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
|
|
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); // dedup
|
|
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(100));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240, 100 }),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that the very first computed ServiceLevel is always published even when it is
|
|
/// 0 — a node starting Detached (or role-less) must publish 0 rather than let the SDK default
|
|
/// (255 = full service) stand. The dedup against the <c>byte</c>-default <c>0</c> must not swallow
|
|
/// the first publish. Drives a Detached local entry (first computed level = 0) with no DB-health
|
|
/// probe and asserts the publisher saw exactly [0].</summary>
|
|
[Fact]
|
|
public void First_service_level_zero_is_published_not_deduped()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("detached-node");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local));
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Detached,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 0 }),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader.</summary>
|
|
[Fact]
|
|
public void RedundancyStateChanged_drives_local_ServiceLevel_publish_for_primary_leader()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("primary-node");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local));
|
|
|
|
var snapshot = new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
new NodeRedundancyState(NodeId.Parse("other-node"), RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId());
|
|
actor.Tell(snapshot);
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 240 }),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that RedundancyStateChanged for secondary publishes 100.</summary>
|
|
[Fact]
|
|
public void RedundancyStateChanged_for_secondary_publishes_100()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("secondary-node");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(serviceLevel: publisher, localNode: local));
|
|
|
|
var snapshot = new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId());
|
|
actor.Tell(snapshot);
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldBe(new byte[] { 100 }),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that the calculator path computes 250 for a healthy primary role-leader
|
|
/// (basis 240 from DB-reachable + probe-ok + fresh, +10 driver-role-leader bonus).</summary>
|
|
[Fact]
|
|
public void Calculator_path_healthy_primary_leader_publishes_250()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("primary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that the calculator path computes 240 for a healthy non-leader secondary
|
|
/// (basis 240 from DB-reachable + probe-ok + fresh, no leader bonus). Documented change from the
|
|
/// legacy role-only path, which mapped Secondary → 100.</summary>
|
|
[Fact]
|
|
public void Calculator_path_healthy_secondary_publishes_240()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("secondary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that the calculator path computes 100 when the DB is unreachable
|
|
/// ((false,_,true) basis, stale via !DbReachable) for a non-leader secondary (no bonus).</summary>
|
|
[Fact]
|
|
public void Calculator_path_db_unreachable_publishes_100()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("secondary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down"))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down"));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)100),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that the calculator path computes 200 for a stale snapshot when the DB is
|
|
/// reachable + fresh but the redundancy entry's AsOfUtc is older than the stale window
|
|
/// ((true,_,true) basis) for a non-leader secondary (no bonus).</summary>
|
|
[Fact]
|
|
public void Calculator_path_stale_snapshot_publishes_200()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("secondary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
|
|
staleWindow: TimeSpan.FromSeconds(2)));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false,
|
|
DateTime.UtcNow - TimeSpan.FromMinutes(1)),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that a detached local node publishes 0 (the calculator does not model
|
|
/// Detached, so the handler guards it before the calculator path). The node first goes healthy
|
|
/// (250) so the dedup'd transition down to 0 is observable.</summary>
|
|
[Fact]
|
|
public void Calculator_path_detached_publishes_0()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("detached-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
// First go healthy primary-leader (250) so the transition down to 0 is not dedup'd against
|
|
// the initial 0.
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
|
duration: PresenceBudget);
|
|
|
|
// Now detach — expect the guard to drive ServiceLevel down to 0.
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Detached,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that an actively-observed, recent peer probe of MY endpoint that came back
|
|
/// <c>Ok==false</c> demotes the calculator basis to 0. A non-leader Secondary entry (no +10 bonus)
|
|
/// is used so the assertion is unambiguous: inputs (DbReachable=true, OpcUaProbeOk=false,
|
|
/// Stale=false) match Compute's <c>_ => 0</c> arm → 0, +0 (non-leader) → 0.</summary>
|
|
[Fact]
|
|
public void Probe_false_about_me_with_healthy_db_publishes_0()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("secondary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
|
|
staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies branch (3) of <c>OpcUaProbeOk()</c>: a peer's NEGATIVE verdict about this node
|
|
/// that has aged past <c>_probeFreshnessWindow</c> is given the benefit of the doubt (the peer's
|
|
/// verdict aged out → don't demote), so the node still publishes the HEALTHY level. With a 1ms
|
|
/// freshness window and a 30ms wait before the recompute, the cached <c>Ok==false</c> verdict is
|
|
/// reliably stale by the time the level is computed, so <c>OpcUaProbeOk()</c> returns true and a
|
|
/// healthy primary-leader computes inputs (true, true, false) → 240, +10 leader → 250 — NOT the 0
|
|
/// that a still-fresh negative verdict would produce (cf. <see cref="Probe_false_about_me_with_healthy_db_publishes_0"/>).</summary>
|
|
[Fact]
|
|
public void Stale_probe_verdict_is_not_demoted_publishes_healthy()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("primary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
|
|
staleWindow: TimeSpan.FromSeconds(30),
|
|
probeFreshnessWindow: TimeSpan.FromMilliseconds(1)));
|
|
|
|
// Seed a healthy DB sample and a NEGATIVE peer verdict about me. (No RedundancyStateChanged yet,
|
|
// so no level is computed from these — the verdict just gets stamped with the receive time.)
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
|
|
|
|
// Let the verdict age WELL past the 1ms freshness window before any level is computed. The 30ms
|
|
// wait is >> 1ms, so the verdict is reliably stale by the time OpcUaProbeOk() runs — no reliance
|
|
// on sub-ms clock ties.
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(30));
|
|
|
|
// Now trigger the recompute with a fresh healthy primary-leader snapshot. By now _probeAboutMe is
|
|
// >1ms old, so branch (3) ignores the negative verdict and OpcUaProbeOk() returns true.
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
// Healthy (250), NOT 0 — proves an aged negative verdict does not demote.
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that with no peer probe result ever received, <c>OpcUaProbeOk()</c> defaults
|
|
/// to <c>true</c> (benefit of the doubt / single-node). A healthy primary-leader thus computes
|
|
/// inputs (true, true, false) → 240, +10 leader → 250.</summary>
|
|
[Fact]
|
|
public void No_probe_result_defaults_ok_true_publishes_250()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("primary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
|
|
staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that a later <c>Ok==true</c> peer probe supersedes an earlier <c>Ok==false</c>
|
|
/// (recovery). A non-leader Secondary entry is used (no +10 bonus): after the true supersedes,
|
|
/// inputs (true, true, false) → 240, +0 → 240.</summary>
|
|
[Fact]
|
|
public void Probe_true_supersedes_earlier_false_publishes_240()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("secondary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
|
|
staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false));
|
|
actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: true));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that a peer probe result about a DIFFERENT node is ignored — it does not
|
|
/// affect MY <c>OpcUaProbeOk()</c>, which stays at its default <c>true</c>. A healthy
|
|
/// primary-leader thus still computes (true, true, false) → 240, +10 → 250.</summary>
|
|
[Fact]
|
|
public void Probe_about_a_different_node_is_ignored()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("primary-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
|
|
staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(NodeId.Parse("someone-else"), Ok: false));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies the legacy back-compat seam: with no DB-health probe wired, the handler
|
|
/// falls back to the old role-only switch (Primary + leader → 240).</summary>
|
|
[Fact]
|
|
public void Legacy_path_no_db_probe_keeps_role_only()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("primary-node");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local));
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that the periodic <c>HealthTick</c> Asks the local <see cref="DbHealthProbeActor"/>
|
|
/// for status and pipes the reply back to itself — populating <c>_lastDbHealth</c> in production WITHOUT
|
|
/// any external pump (no direct <see cref="DbHealthProbeActor.DbHealthStatus"/> Tell). With a healthy DB
|
|
/// reply and a primary-leader snapshot, the calculator path therefore reaches 250.</summary>
|
|
[Fact]
|
|
public void HealthTick_asks_db_probe_and_publishes_calculator_byte()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("primary-node");
|
|
var db = Sys.ActorOf(Akka.Actor.Props.Create(() => new StubDbHealth(
|
|
new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: db,
|
|
staleWindow: TimeSpan.FromSeconds(30), probeFreshnessWindow: TimeSpan.FromSeconds(30),
|
|
healthTickInterval: TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new RedundancyStateChanged(new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
}, CorrelationId.NewId()));
|
|
// No direct DbHealthStatus Tell — the periodic HealthTick Ask must populate it → 250.
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), TimeSpan.FromSeconds(2));
|
|
}
|
|
|
|
// ── Per-cluster mesh Phase 4: DB-less driver-only nodes ─────────────────────────────────────
|
|
// A driver-only node has no ConfigDb (LocalDb is its config store), so there is no DB to probe.
|
|
// Such nodes run with dbHealthProbe: null + dbLess: true and must still publish the full
|
|
// survive-alone ServiceLevel (240 follower / 250 Primary) — DbReachable is treated as a
|
|
// constant true because the in-process LocalDb can never be "unreachable"; only snapshot
|
|
// staleness demotes.
|
|
|
|
/// <summary>DB-less, member Up, snapshot fresh, follower (non-Primary) → 240. No probe wired,
|
|
/// no DbHealthStatus ever arrives; the DB-less branch must still reach full service.</summary>
|
|
[Fact]
|
|
public void DbLess_healthy_follower_publishes_240()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("db-less-follower");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbLess: true,
|
|
staleWindow: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>DB-less, member Up, snapshot fresh, Primary → 250 (basis 240 + 10 Primary bonus).</summary>
|
|
[Fact]
|
|
public void DbLess_healthy_primary_publishes_250()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("db-less-primary");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbLess: true,
|
|
staleWindow: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>DB-less, snapshot stale (entry.AsOfUtc older than the stale window) → 200. Staleness
|
|
/// is derived from snapshot age ALONE (no DB-health term), so a DB-less node still demotes to 200
|
|
/// when it is running behind on config.</summary>
|
|
[Fact]
|
|
public void DbLess_stale_snapshot_publishes_200()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("db-less-stale");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbLess: true,
|
|
staleWindow: TimeSpan.FromSeconds(2)));
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false,
|
|
DateTime.UtcNow - TimeSpan.FromMinutes(1)),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>DB-less, Detached → 0. The Detached / missing-entry guard runs ABOVE the DB-less
|
|
/// branch, so a DB-less node that detaches still fails closed to 0. Goes healthy (250) first so
|
|
/// the transition down to 0 is observable (not dedup'd against the initial 0).</summary>
|
|
[Fact]
|
|
public void DbLess_detached_publishes_0()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("db-less-detached");
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbLess: true,
|
|
staleWindow: TimeSpan.FromSeconds(30)));
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Primary,
|
|
IsClusterLeader: true, IsDriverPrimary: true, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
|
|
duration: PresenceBudget);
|
|
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Detached,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Regression: a DB-BACKED node (probe wired + DbHealthStatus reachable, dbLess default
|
|
/// false) is unchanged — it still computes 240 via the calculator path. Proves the new DB-less
|
|
/// branch did not disturb the existing DB-backed seam.</summary>
|
|
[Fact]
|
|
public void DbBacked_node_unchanged_still_publishes_240()
|
|
{
|
|
var publisher = new RecordingPublisher();
|
|
var local = NodeId.Parse("db-backed-node");
|
|
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
|
|
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
|
|
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
|
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
|
|
|
|
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
|
|
actor.Tell(new RedundancyStateChanged(
|
|
Nodes: new[]
|
|
{
|
|
new NodeRedundancyState(local, RedundancyRole.Secondary,
|
|
IsClusterLeader: false, IsDriverPrimary: false, DateTime.UtcNow),
|
|
},
|
|
CorrelationId.NewId()));
|
|
|
|
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
|
|
duration: PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/>
|
|
/// with a fixed status, so the calculator path is deterministic without a real timer.</summary>
|
|
private sealed class StubDbHealth : Akka.Actor.ReceiveActor
|
|
{
|
|
/// <summary>Initializes a new instance of the <see cref="StubDbHealth"/> class.</summary>
|
|
/// <param name="status">The fixed DB-health status to reply with.</param>
|
|
public StubDbHealth(DbHealthProbeActor.DbHealthStatus status) =>
|
|
Receive<DbHealthProbeActor.GetStatus>(_ => Sender.Tell(status));
|
|
}
|
|
|
|
/// <summary>Test implementation of IOpcUaAddressSpaceSink that records calls.</summary>
|
|
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
|
{
|
|
/// <summary>Gets the queue of recorded value updates.</summary>
|
|
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
|
|
/// <summary>Gets the queue of recorded alarm condition updates.</summary>
|
|
public ConcurrentQueue<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> AlarmQueue { get; } = new();
|
|
/// <summary>Gets the queue of recorded alarm-quality annotations (#477).</summary>
|
|
public ConcurrentQueue<(string AlarmNodeId, OpcUaQuality Quality, DateTime Ts, AddressSpaceRealm Realm)> AlarmQualityQueue { get; } = new();
|
|
/// <summary>Count of rebuild calls.</summary>
|
|
public int RebuildCalls;
|
|
/// <summary>Gets the queue of recorded EnsureFolder calls.</summary>
|
|
public ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> FolderQueue { get; } = new();
|
|
/// <summary>Gets the queue of recorded EnsureVariable calls.</summary>
|
|
public ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new();
|
|
/// <summary>Gets the queue of recorded RaiseNodesAddedModelChange announcements.</summary>
|
|
public ConcurrentQueue<string> ModelChangeQueue { get; } = new();
|
|
|
|
/// <summary>Gets the list of recorded value updates.</summary>
|
|
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values =>
|
|
ValueQueue.ToList();
|
|
/// <summary>Gets the list of recorded alarm condition updates.</summary>
|
|
public List<(string AlarmNodeId, AlarmConditionSnapshot State, DateTime Ts)> Alarms =>
|
|
AlarmQueue.ToList();
|
|
/// <summary>Gets the list of recorded EnsureFolder calls.</summary>
|
|
public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders =>
|
|
FolderQueue.ToList();
|
|
/// <summary>Gets the list of recorded EnsureVariable calls.</summary>
|
|
public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables =>
|
|
VariableQueue.ToList();
|
|
/// <summary>Gets the list of recorded RaiseNodesAddedModelChange announcements.</summary>
|
|
public List<string> ModelChanges => ModelChangeQueue.ToList();
|
|
|
|
/// <summary>Records a value update.</summary>
|
|
/// <param name="nodeId">The OPC UA node identifier.</param>
|
|
/// <param name="value">The attribute value.</param>
|
|
/// <param name="quality">The OPC UA quality code.</param>
|
|
/// <param name="ts">The timestamp of the update.</param>
|
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
|
ValueQueue.Enqueue((nodeId, value, quality, ts));
|
|
|
|
/// <summary>Records an alarm condition update.</summary>
|
|
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
|
|
/// <param name="state">The full condition state snapshot.</param>
|
|
/// <param name="ts">The timestamp of the update.</param>
|
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
|
AlarmQueue.Enqueue((alarmNodeId, state, ts));
|
|
|
|
/// <summary>Records an alarm-quality annotation (#477).</summary>
|
|
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
|
AlarmQualityQueue.Enqueue((alarmNodeId, quality, ts, realm));
|
|
|
|
/// <summary>Materialises an alarm condition (no-op in test).</summary>
|
|
/// <param name="alarmNodeId">The alarm node ID.</param>
|
|
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
|
/// <param name="displayName">The condition display name.</param>
|
|
/// <param name="alarmType">The domain alarm type.</param>
|
|
/// <param name="severity">The domain severity.</param>
|
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
|
|
|
/// <summary>Records a folder ensure call.</summary>
|
|
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
|
|
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
|
|
/// <param name="displayName">The display name of the folder.</param>
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
|
FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
|
|
|
|
/// <summary>Records a variable ensure call.</summary>
|
|
/// <param name="variableNodeId">The OPC UA variable node identifier.</param>
|
|
/// <param name="parentFolderNodeId">The parent folder node identifier, or null for root.</param>
|
|
/// <param name="displayName">The display name of the variable.</param>
|
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
|
/// <param name="writable">Whether the node is created read/write.</param>
|
|
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) =>
|
|
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
|
|
|
/// <summary>Records a rebuild call.</summary>
|
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
|
|
|
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
|
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
|
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
|
}
|
|
|
|
/// <summary>Test implementation of IServiceLevelPublisher that records publishes.</summary>
|
|
private sealed class RecordingPublisher : IServiceLevelPublisher
|
|
{
|
|
private readonly ConcurrentQueue<byte> _q = new();
|
|
/// <summary>Gets the recorded service levels.</summary>
|
|
public byte[] Levels => _q.ToArray();
|
|
/// <summary>Records a service level publish.</summary>
|
|
/// <param name="serviceLevel">The service level value to publish.</param>
|
|
public void Publish(byte serviceLevel) => _q.Enqueue(serviceLevel);
|
|
}
|
|
}
|