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

432 lines
22 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("InAlarm", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt),
new AbCipStructureMember("In", AbCipDataType.DInt),
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // input-health flag — NOT the alarm
]);
/// <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.InAlarm");
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 InAlarm 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; // InAlarm=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InAlarm+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 InAlarm=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; // InAlarm=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>
/// hw4 — ALMD's alarm-active output is <c>InAlarm</c> (Rockwell ALMD reference); <c>InFaulted</c> (Status.1) is
/// the INPUT-health flag. The projection used to poll InFaulted, so a live ALMD would raise on an input fault
/// and never on the alarm itself.
/// </summary>
[Fact]
public async Task An_input_fault_is_not_an_alarm_and_InAlarm_is()
{
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] = 0; // InAlarm clear
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[16] = 1; // InFaulted set: input health bad, not an alarm
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
lock (events) events.ShouldBeEmpty("an input fault (InFaulted) is not an alarm");
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // the alarm condition
await WaitForEventAsync(events, e => e.Kind == AlarmTransitionKind.Raise);
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>hw4 F2 — an ALMD that declared members but no InAlarm (a pre-hw4 InFaulted config) would read
/// BadNodeIdUnknown forever; it logs ONE Warning per tag instead of staying silent.</summary>
[Fact]
public async Task An_almd_without_a_declared_InAlarm_warns_once()
{
var legacy = new AbCipTagDefinition("Legacy", Device, "Legacy", AbCipDataType.Structure, Members:
[new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt)]);
var log = new CapturingLogger();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(legacy),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", new FakeAbCipTagFactory(), logger: log);
await drv.InitializeAsync("{}", CancellationToken.None);
var h1 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None);
var h2 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(h1, CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(h2, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
log.Warnings.Count(m => m.Contains("Legacy") && m.Contains("InAlarm")).ShouldBe(1);
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbCipDriver>
{
public List<string> Warnings { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception));
}
}
/// <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);
// Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and
// projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every
// ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if
// it had routed.
AlarmEventArgs raised, cleared;
lock (events)
{
raised = events.First(e => e.Message.Contains("raised"));
cleared = events.First(e => e.Message.Contains("cleared"));
}
raised.ConditionId.ShouldBe("HighTemp");
raised.Kind.ShouldBe(AlarmTransitionKind.Raise);
cleared.ConditionId.ShouldBe("HighTemp");
cleared.Kind.ShouldBe(AlarmTransitionKind.Clear);
}
/// <summary>
/// Gitea #641 review — the server carries every surviving native condition's state (a client's
/// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every
/// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so
/// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm
/// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set.
/// </summary>
[Fact]
public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm()
{
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.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
await WaitForEventAsync(raises, _ => true);
// A reconnect / alarm-set change: the same ref, still faulted.
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm");
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh
/// (inactive) when it came back, so it is raised again.</summary>
[Fact]
public async Task A_ref_that_left_the_set_and_came_back_is_raised_again()
{
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.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
await WaitForEventAsync(raises, _ => true);
await drv.SubscribeAlarmsAsync([], CancellationToken.None);
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForEventAsync(raises, _ => raises.Count >= 2);
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.");
}
}