feat(scripted-alarms): DependencyMuxTagUpstreamSource (T7)
Concrete ITagUpstreamSource the scripted-alarm host actor pushes DependencyValueChanged values into and ScriptedAlarmEngine reads/subscribes from. Thread-safe: ConcurrentDictionary value cache + per-path ImmutableList observer lists with atomic add/remove and capture-then-invoke fan-out. ReadTag of an unknown path returns a Bad-quality (0x80000000) snapshot stamped via the injected clock. Adds the Core.ScriptedAlarms project reference Runtime needs to see the interface.
This commit is contained in:
+145
@@ -0,0 +1,145 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DependencyMuxTagUpstreamSource"/> — the host-actor-fed
|
||||
/// <c>ITagUpstreamSource</c> the scripted-alarm engine reads + subscribes through.
|
||||
/// </summary>
|
||||
public sealed class DependencyMuxTagUpstreamSourceTests
|
||||
{
|
||||
private static DataValueSnapshot Good(object? value, DateTime ts)
|
||||
=> new(value, 0u, ts, ts);
|
||||
|
||||
[Fact]
|
||||
public void Push_then_ReadTag_returns_the_pushed_snapshot()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var snap = Good(42, now);
|
||||
|
||||
src.Push("a/b/c", snap);
|
||||
|
||||
src.ReadTag("a/b/c").ShouldBeSameAs(snap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadTag_of_unknown_path_returns_a_Bad_quality_snapshot()
|
||||
{
|
||||
var fixedNow = new DateTime(2026, 06, 10, 9, 30, 0, DateTimeKind.Utc);
|
||||
var src = new DependencyMuxTagUpstreamSource(clock: () => fixedNow);
|
||||
|
||||
var snap = src.ReadTag("never/pushed");
|
||||
|
||||
snap.Value.ShouldBeNull();
|
||||
// Bad: OPC UA Part 4 StatusCode bit 31 set (severity 10).
|
||||
(snap.StatusCode & 0x80000000u).ShouldNotBe(0u);
|
||||
snap.StatusCode.ShouldNotBe(0u);
|
||||
// Timestamp comes from the injected clock.
|
||||
snap.ServerTimestampUtc.ShouldBe(fixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeTag_observer_fires_on_next_push_to_same_path_only()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var received = new List<(string Path, DataValueSnapshot Snap)>();
|
||||
|
||||
using var sub = src.SubscribeTag("tag/x", (p, s) => received.Add((p, s)));
|
||||
|
||||
// A push to a DIFFERENT path must NOT fire this observer.
|
||||
src.Push("tag/other", Good(1, now));
|
||||
received.ShouldBeEmpty();
|
||||
|
||||
var snap = Good(7, now);
|
||||
src.Push("tag/x", snap);
|
||||
|
||||
received.Count.ShouldBe(1);
|
||||
received[0].Path.ShouldBe("tag/x");
|
||||
received[0].Snap.ShouldBeSameAs(snap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_observers_on_same_path_all_fire()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var a = 0;
|
||||
var b = 0;
|
||||
|
||||
using var subA = src.SubscribeTag("tag/y", (_, _) => a++);
|
||||
using var subB = src.SubscribeTag("tag/y", (_, _) => b++);
|
||||
|
||||
src.Push("tag/y", Good(1, now));
|
||||
|
||||
a.ShouldBe(1);
|
||||
b.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_a_subscription_stops_delivery_to_that_observer_only()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var a = 0;
|
||||
var b = 0;
|
||||
|
||||
var subA = src.SubscribeTag("tag/z", (_, _) => a++);
|
||||
using var subB = src.SubscribeTag("tag/z", (_, _) => b++);
|
||||
|
||||
src.Push("tag/z", Good(1, now));
|
||||
a.ShouldBe(1);
|
||||
b.ShouldBe(1);
|
||||
|
||||
subA.Dispose();
|
||||
|
||||
src.Push("tag/z", Good(2, now));
|
||||
a.ShouldBe(1); // disposed — no further delivery
|
||||
b.ShouldBe(2); // still subscribed
|
||||
|
||||
// Dispose-after-already-removed must be a no-op, not throw.
|
||||
Should.NotThrow(() => subA.Dispose());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_push_and_subscribe_does_not_throw()
|
||||
{
|
||||
var src = new DependencyMuxTagUpstreamSource();
|
||||
var now = new DateTime(2026, 06, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new List<IDisposable>();
|
||||
var subsLock = new object();
|
||||
Exception? failure = null;
|
||||
|
||||
var pusher = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 2000; i++)
|
||||
src.Push("hot/path", Good(i, now));
|
||||
}
|
||||
catch (Exception ex) { failure = ex; }
|
||||
});
|
||||
|
||||
var subscriber = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 2000; i++)
|
||||
{
|
||||
var d = src.SubscribeTag("hot/path", (_, _) => { });
|
||||
lock (subsLock) subs.Add(d);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { failure = ex; }
|
||||
});
|
||||
|
||||
await Task.WhenAll(pusher, subscriber);
|
||||
|
||||
failure.ShouldBeNull();
|
||||
foreach (var d in subs) d.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user