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:
Joseph Doherty
2026-06-10 14:20:02 -04:00
parent b5748288df
commit 945ccd0b85
3 changed files with 309 additions and 0 deletions
@@ -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();
}
}