167 lines
8.1 KiB
C#
167 lines
8.1 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
|
|
|
/// <summary>
|
|
/// WP2.1b — the alarm subscriber first-segment bucket index must make EXACTLY the routing
|
|
/// decisions the previous linear scan made. The expectation in each case is computed with
|
|
/// the original rule (plain <c>StartsWith</c> against every subscribed source), so the
|
|
/// test pins equivalence rather than restating the new implementation.
|
|
/// </summary>
|
|
public class DataConnectionActorAlarmIndexTests : TestKit
|
|
{
|
|
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
|
|
private readonly IDataConnectionFactory _factory = Substitute.For<IDataConnectionFactory>();
|
|
private readonly DataConnectionOptions _options = new()
|
|
{
|
|
ReconnectInterval = TimeSpan.FromMilliseconds(100),
|
|
TagResolutionRetryInterval = TimeSpan.FromMilliseconds(200),
|
|
WriteTimeout = TimeSpan.FromSeconds(5)
|
|
};
|
|
|
|
// Sources chosen to exercise every index path:
|
|
// - "Area1" → NO separator: the residue list (can match other buckets)
|
|
// - "Area1.Tank" → bucket "Area1", shared prefix with the next one
|
|
// - "Area1.Tank1.Sub" → bucket "Area1", deeper
|
|
// - "Area2.Tank" → a different bucket
|
|
private static readonly string[] Sources = ["Area1", "Area1.Tank", "Area1.Tank1.Sub", "Area2.Tank"];
|
|
|
|
private static readonly (string SourceRef, string SourceObjectRef)[] Transitions =
|
|
[
|
|
("Area1.Tank1.Hi", "Area1.Tank1"), // Area1 (residue) + Area1.Tank
|
|
("Area1.Tank1.Sub.Hi", "Area1.Tank1.Sub"), // + Area1.Tank1.Sub
|
|
("Area1X.Pump.Hi", "Area1X.Pump"), // sub-segment prefix: Area1 ONLY
|
|
("Area2.Tank9.Hi", "Area2.Tank9"), // Area2.Tank only
|
|
("Zone.A.Hi", "Zone.A"), // nobody
|
|
];
|
|
|
|
private static NativeAlarmTransition Transition(string sourceRef, string sourceObj) =>
|
|
new(sourceRef, sourceObj, "AnalogLimit.Hi", AlarmTransitionKind.Raise,
|
|
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500),
|
|
"Process", "hi", "hi", "", "", null, DateTimeOffset.UtcNow, "92", "90");
|
|
|
|
/// <summary>The ORIGINAL linear-scan rule, kept here as the oracle.</summary>
|
|
private static bool LinearScanMatches(string sourceRef, string transitionSourceRef, string transitionSourceObjectRef) =>
|
|
transitionSourceObjectRef.StartsWith(sourceRef, StringComparison.Ordinal)
|
|
|| transitionSourceRef.StartsWith(sourceRef, StringComparison.Ordinal);
|
|
|
|
[Fact]
|
|
public void BucketIndex_RoutesExactlyLikeTheLinearScan()
|
|
{
|
|
AlarmTransitionCallback? cb = null;
|
|
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
|
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
((IAlarmSubscribableConnection)adapter)
|
|
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
|
.Returns(ci => Task.FromResult("alarm-" + ci.ArgAt<string>(0)));
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
|
"conn", adapter, _options, _health, _factory, "OpcUa")), "alarm-index");
|
|
|
|
// One probe per source so the routing decision per source is observable.
|
|
var probes = Sources.ToDictionary(s => s, _ => CreateTestProbe());
|
|
foreach (var source in Sources)
|
|
{
|
|
actor.Tell(new SubscribeAlarmsRequest("c", "inst-" + source, "conn", source, null, DateTimeOffset.UtcNow),
|
|
probes[source].Ref);
|
|
probes[source].ExpectMsg<SubscribeAlarmsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
Assert.NotNull(cb);
|
|
|
|
foreach (var (sourceRef, sourceObjectRef) in Transitions)
|
|
{
|
|
cb!(Transition(sourceRef, sourceObjectRef));
|
|
|
|
foreach (var source in Sources)
|
|
{
|
|
if (LinearScanMatches(source, sourceRef, sourceObjectRef))
|
|
{
|
|
probes[source].ExpectMsg<NativeAlarmTransitionUpdate>(
|
|
u => u.Transition.SourceReference == sourceRef, TimeSpan.FromSeconds(5));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing extra was delivered to anybody.
|
|
foreach (var source in Sources)
|
|
probes[source].ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
}
|
|
|
|
[Fact]
|
|
public void SnapshotComplete_StillBroadcastsToEverySubscriber_BypassingTheIndex()
|
|
{
|
|
// The framing sentinel carries an EMPTY source reference, so it matches no bucket.
|
|
// It must still reach every alarm subscriber, or statically-active conditions
|
|
// delivered only in the snapshot would buffer forever.
|
|
AlarmTransitionCallback? cb = null;
|
|
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
|
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
((IAlarmSubscribableConnection)adapter)
|
|
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
|
.Returns(ci => Task.FromResult("alarm-" + ci.ArgAt<string>(0)));
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
|
"conn", adapter, _options, _health, _factory, "OpcUa")), "alarm-index-snapshot");
|
|
|
|
var probes = Sources.ToDictionary(s => s, _ => CreateTestProbe());
|
|
foreach (var source in Sources)
|
|
{
|
|
actor.Tell(new SubscribeAlarmsRequest("c", "inst-" + source, "conn", source, null, DateTimeOffset.UtcNow),
|
|
probes[source].Ref);
|
|
probes[source].ExpectMsg<SubscribeAlarmsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
Assert.NotNull(cb);
|
|
cb!(new NativeAlarmTransition(
|
|
"", "", "", AlarmTransitionKind.SnapshotComplete,
|
|
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
|
|
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", ""));
|
|
|
|
foreach (var source in Sources)
|
|
{
|
|
probes[source].ExpectMsg<NativeAlarmTransitionUpdate>(
|
|
u => u.Transition.Kind == AlarmTransitionKind.SnapshotComplete, TimeSpan.FromSeconds(5));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void UnsubscribedSource_IsDroppedFromTheIndex()
|
|
{
|
|
AlarmTransitionCallback? cb = null;
|
|
var adapter = Substitute.For<IDataConnection, IAlarmSubscribableConnection>();
|
|
adapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.CompletedTask);
|
|
((IAlarmSubscribableConnection)adapter)
|
|
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(),
|
|
Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
|
|
.Returns(ci => Task.FromResult("alarm-" + ci.ArgAt<string>(0)));
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor(
|
|
"conn", adapter, _options, _health, _factory, "OpcUa")), "alarm-index-unsub");
|
|
|
|
var probe = CreateTestProbe();
|
|
actor.Tell(new SubscribeAlarmsRequest("c", "inst", "conn", "Area1.Tank", null, DateTimeOffset.UtcNow), probe.Ref);
|
|
probe.ExpectMsg<SubscribeAlarmsResponse>(m => m.Success, TimeSpan.FromSeconds(5));
|
|
|
|
actor.Tell(new UnsubscribeAlarmsRequest("c2", "inst", "conn", "Area1.Tank", DateTimeOffset.UtcNow), probe.Ref);
|
|
Thread.Sleep(200);
|
|
|
|
Assert.NotNull(cb);
|
|
cb!(Transition("Area1.Tank1.Hi", "Area1.Tank1"));
|
|
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
}
|