Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Telemetry/TelemetryLocalHubTests.cs
T

174 lines
6.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Telemetry;
/// <summary>
/// Verifies the node-local telemetry hub (per-cluster mesh Phase 5): snapshot-replay of the two
/// snapshot-style channels (Health / Resilience), live-only forwarding of the append-style logs
/// (Alarm / Script), independent bounded per-subscriber fan-out, DropOldest backpressure, and
/// detach-on-dispose. The hub carries ONLY this node's own telemetry — it never touches DPS.
/// </summary>
public sealed class TelemetryLocalHubTests
{
private static DriverHealthChanged Health(string instanceId, string state = "Healthy") =>
new("cluster-a", instanceId, state, null, null, 0, DateTime.UtcNow);
private static DriverResilienceStatusChanged Resilience(string instanceId, string host) =>
new(instanceId, host, false, 0, 0, null, DateTime.UtcNow, DateTime.UtcNow);
private static AlarmTransitionEvent Alarm(string id) =>
new(id, "Area/Line/Equip", "AlarmName", "Activated", 500, "msg", "system", DateTime.UtcNow);
private static ScriptLogEntry Script(string id) =>
new(id, "Information", "log", DateTime.UtcNow, null, null, null);
private static List<TelemetryItem> Drain(ITelemetrySubscription sub)
{
var items = new List<TelemetryItem>();
while (sub.Reader.TryRead(out var item))
items.Add(item);
return items;
}
[Fact]
public void New_subscriber_is_primed_with_cached_Health_snapshot()
{
var hub = new TelemetryLocalHub();
hub.Emit(new TelemetryItem.Health(Health("drv-1", "Faulted")));
using var sub = hub.Subscribe(boundedCapacity: 16);
var items = Drain(sub);
items.ShouldHaveSingleItem();
var health = items[0].ShouldBeOfType<TelemetryItem.Health>();
health.E.DriverInstanceId.ShouldBe("drv-1");
health.E.State.ShouldBe("Faulted");
}
[Fact]
public void New_subscriber_is_primed_with_cached_Resilience_snapshot()
{
var hub = new TelemetryLocalHub();
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a")));
using var sub = hub.Subscribe(boundedCapacity: 16);
var items = Drain(sub);
items.ShouldHaveSingleItem();
var res = items[0].ShouldBeOfType<TelemetryItem.Resilience>();
res.E.DriverInstanceId.ShouldBe("drv-1");
res.E.HostName.ShouldBe("host-a");
}
[Fact]
public void Alarm_and_Script_are_not_replayed_to_a_new_subscriber()
{
var hub = new TelemetryLocalHub();
hub.Emit(new TelemetryItem.Alarm(Alarm("a-1")));
hub.Emit(new TelemetryItem.Script(Script("s-1")));
using var sub = hub.Subscribe(boundedCapacity: 16);
Drain(sub).ShouldBeEmpty();
}
[Fact]
public void Two_concurrent_subscribers_each_receive_a_post_subscribe_emit()
{
var hub = new TelemetryLocalHub();
using var s1 = hub.Subscribe(boundedCapacity: 16);
using var s2 = hub.Subscribe(boundedCapacity: 16);
hub.Emit(new TelemetryItem.Alarm(Alarm("a-1")));
Drain(s1).ShouldHaveSingleItem().ShouldBeOfType<TelemetryItem.Alarm>().E.AlarmId.ShouldBe("a-1");
Drain(s2).ShouldHaveSingleItem().ShouldBeOfType<TelemetryItem.Alarm>().E.AlarmId.ShouldBe("a-1");
}
[Fact]
public void Health_snapshot_keeps_only_the_latest_per_instance()
{
var hub = new TelemetryLocalHub();
hub.Emit(new TelemetryItem.Health(Health("drv-1", "Reconnecting")));
hub.Emit(new TelemetryItem.Health(Health("drv-1", "Healthy")));
using var sub = hub.Subscribe(boundedCapacity: 16);
var items = Drain(sub);
items.ShouldHaveSingleItem();
items[0].ShouldBeOfType<TelemetryItem.Health>().E.State.ShouldBe("Healthy");
}
[Fact]
public void Health_snapshot_keeps_distinct_instances()
{
var hub = new TelemetryLocalHub();
hub.Emit(new TelemetryItem.Health(Health("drv-1")));
hub.Emit(new TelemetryItem.Health(Health("drv-2")));
using var sub = hub.Subscribe(boundedCapacity: 16);
var ids = Drain(sub)
.OfType<TelemetryItem.Health>()
.Select(h => h.E.DriverInstanceId)
.OrderBy(x => x)
.ToArray();
ids.ShouldBe(new[] { "drv-1", "drv-2" });
}
[Fact]
public void Resilience_snapshot_keeps_only_latest_per_instance_and_host()
{
var hub = new TelemetryLocalHub();
// Same instance, two distinct hosts → two cache entries.
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a")));
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-b")));
// Overwrite (drv-1, host-a).
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a")));
using var sub = hub.Subscribe(boundedCapacity: 16);
var keys = Drain(sub)
.OfType<TelemetryItem.Resilience>()
.Select(r => (r.E.DriverInstanceId, r.E.HostName))
.OrderBy(x => x.HostName)
.ToArray();
keys.ShouldBe(new[] { ("drv-1", "host-a"), ("drv-1", "host-b") });
}
[Fact]
public void Bounded_channel_drops_oldest_not_newest_when_full()
{
var hub = new TelemetryLocalHub();
using var sub = hub.Subscribe(boundedCapacity: 2);
// Append-style (uncached) items so the snapshot doesn't consume capacity.
hub.Emit(new TelemetryItem.Alarm(Alarm("a-1")));
hub.Emit(new TelemetryItem.Alarm(Alarm("a-2")));
hub.Emit(new TelemetryItem.Alarm(Alarm("a-3")));
var ids = Drain(sub).OfType<TelemetryItem.Alarm>().Select(a => a.E.AlarmId).ToArray();
// a-1 (oldest) evicted; the two newest survive in order.
ids.ShouldBe(new[] { "a-2", "a-3" });
}
[Fact]
public void Dispose_detaches_the_subscriber_and_completes_its_channel()
{
var hub = new TelemetryLocalHub();
var sub = hub.Subscribe(boundedCapacity: 16);
sub.Dispose();
// A further Emit must neither throw nor deliver to the detached subscriber.
Should.NotThrow(() => hub.Emit(new TelemetryItem.Alarm(Alarm("a-1"))));
sub.Reader.Completion.IsCompleted.ShouldBeTrue();
sub.Reader.TryRead(out _).ShouldBeFalse();
}
}