Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs
T

290 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAlarmProjectionTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static AbCipTagDefinition AlmdTag(string name) => new(
name, Device, name, AbCipDataType.Structure, Members:
[
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt),
new AbCipStructureMember("In", AbCipDataType.DInt),
]);
/// <summary>Verifies that ALMD structure signature is correctly detected as an alarm.</summary>
[Fact]
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
{
var almd = AlmdTag("HighTemp");
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
[new AbCipStructureMember("X", AbCipDataType.DInt)]);
AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();
var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
}
/// <summary>Verifies that severity values map correctly to OPC UA alarm severity levels.</summary>
[Fact]
public void Severity_Mapping_Matches_OPC_UA_Convention()
{
// Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing.
AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
}
private static AbCipDriverOptions Options(bool enabled) => new()
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = enabled,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
// so it needs the declaration-only whole-UDT grouping fast path (Driver.AbCip-003).
EnableDeclarationOnlyUdtGrouping = true,
};
/// <summary>Verifies that disabled alarm projection returns a valid handle but does not poll.</summary>
[Fact]
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: false), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
handle.ShouldNotBeNull();
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
// Positive control (#583): the same options with the flag ON poll three times — so a loop this
// subscription had started would have read (and so created) the ALMD runtime by now.
await PolledThreeTimesWithTheFlagOnAsync();
factory.Tags.ShouldNotContainKey("HighTemp");
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that enabled alarm projection starts polling and fires raise event on 0-to-1 transition.</summary>
[Fact]
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
// gets created + polled. Set InFaulted offset-value to 0 first (clear), let polls seed the
// "last-seen false" state, then flip to 1 (fault) + wait for the raise event.
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
await WaitForMoreReadsAsync(factory, "HighTemp", 2);
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
await WaitForEventAsync(events, e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
&& e.Message.Contains("raised"));
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Regression for the reconnect poll-loop leak (#399 sibling): the owning
/// DriverInstanceActor re-subscribes alarms on every Connected entry without first calling
/// Unsubscribe, and the driver object (and its projection) survives the in-place reconnect.
/// Each SubscribeAsync used to start a fresh, never-cancelled poll loop — so after N
/// reconnects there were N live loops all polling the device and all firing the same
/// raise/clear transition, producing DUPLICATE alarm events.
///
/// This simulates two re-subscribes (one reconnect) against the same source, then drives
/// ONE 0->1 device transition. After the collapse-to-single-loop fix exactly one loop is
/// alive so the raise fires exactly once; before the fix both loops fire it ⇒ two events.
/// </summary>
[Fact]
public async Task Resubscribe_Collapses_To_Single_Loop_No_Duplicate_Raise()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var raises = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) =>
{
if (e.Message.Contains("raised")) lock (raises) raises.Add(e);
};
// First subscribe creates + starts polling the HighTemp runtime. Seed InFaulted=false +
// a severity so the loop's baseline settles on "not faulted".
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8
await WaitForMoreReadsAsync(factory, "HighTemp", 2); // sub1's loop has seeded its baseline
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reads the same
// shared HighTemp runtime; let it poll (and so seed its own "last-seen false" baseline) too.
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // one 0->1 transition
await WaitForEventAsync(raises, _ => true);
// Processed-count wait (#583), not a fixed delay: six more reads of the shared runtime after the raise,
// so a leaked second loop — which reads it too — has had polls in which to fire its duplicate.
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
lock (raises)
{
raises.Count.ShouldBe(1,
"exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
}
}
/// <summary>Verifies that alarm clear event fires on 1-to-0 transition.</summary>
[Fact]
public async Task Clear_Event_Fires_On_1_to_0_Transition()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
await WaitForEventAsync(events, e => e.Message.Contains("raised"));
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
await WaitForEventAsync(events, e => e.Message.Contains("cleared"));
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
[Fact]
public async Task Unsubscribe_Stops_The_Poll_Loop()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
await WaitForMoreReadsAsync(factory, "HighTemp", 1); // the loop is running
// Unsubscribe awaits the loop's exit, so no read can land after it returns.
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
var postUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
// Positive control (#583): an identical, still-subscribed driver polls three more times on the same
// interval — so the unsubscribed loop, had it survived, would have read again by now.
await PolledThreeTimesWithTheFlagOnAsync();
factory.Tags["HighTemp"].ReadCount.ShouldBe(postUnsubReadCount);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A control driver with the projection ON polls its ALMD runtime three times.</summary>
private static async Task PolledThreeTimesWithTheFlagOnAsync()
{
var factory = new FakeAbCipTagFactory();
var control = new AbCipDriver(Options(enabled: true), "drv-control", factory);
await control.InitializeAsync("{}", CancellationToken.None);
var handle = await control.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
await control.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await control.ShutdownAsync(CancellationToken.None);
}
/// <summary>Waits for <paramref name="more"/> further reads of the fake runtime (a processed-count wait).</summary>
/// <summary>
/// #630 — a re-Initialize on a live driver must dispose the alarm projection's poll loop rather
/// than leave it running (it would keep raising duplicate alarm events through this driver).
/// </summary>
[Fact]
public async Task Reinitialize_Stops_The_Previous_Projection_Poll_Loop()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
var tag = factory.Tags["HighTemp"];
// Positive control: the loop is demonstrably polling before the re-init.
await WaitForMoreReadsAsync(factory, "HighTemp", 3);
await drv.InitializeAsync("{}", CancellationToken.None);
int Reads() => factory.Tags.Values.Sum(t => t.ReadCount); // the loop may re-create its runtime on the new device state
var frozen = Reads();
await Task.Delay(300); // ~15 poll intervals; a live loop would advance the count
Reads().ShouldBe(frozen);
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitForMoreReadsAsync(FakeAbCipTagFactory factory, string tagName, int more)
{
var tag = factory.Tags[tagName];
var target = tag.ReadCount + more;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (tag.ReadCount < target && DateTime.UtcNow < deadline) await Task.Delay(5);
tag.ReadCount.ShouldBeGreaterThanOrEqualTo(target, $"'{tagName}' should have been read {more} more times");
}
private static async Task WaitForEventAsync(List<AlarmEventArgs> events, Func<AlarmEventArgs, bool> match)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
lock (events) if (events.Any(match)) return;
await Task.Delay(5);
}
throw new TimeoutException("The expected alarm event was never raised.");
}
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
{
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (factory.Tags.ContainsKey(tagName)) return;
await Task.Delay(10);
}
throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
}
}