5d7c53bb19
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
407 lines
20 KiB
C#
407 lines
20 KiB
C#
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #611 item 1 — ALMA (<c>ALARM_ANALOG</c>) projection. Driven through fake tag runtimes only:
|
|
/// ab_server has no alarm instructions, and Z35162 has no ALMA/ALMD, so there is no wire-level gate.
|
|
/// Status members are declared BOOL, as on a real controller, so every member is read through its own runtime
|
|
/// (the production read path) rather than the whole-UDT offset fast path.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipAlmaProjectionTests
|
|
{
|
|
private const string Device = "ab://10.0.0.5/1,0";
|
|
private const string Tag = "HighPress";
|
|
|
|
private static readonly string[] Bits =
|
|
[
|
|
"HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "ROCPosInAlarm",
|
|
"HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked", "OperAckAll",
|
|
];
|
|
private static readonly string[] Sevs = ["HHSeverity", "HSeverity", "LSeverity", "LLSeverity", "ROCPosSeverity"];
|
|
|
|
private static AbCipTagDefinition AlmaTag(string name = Tag) => new(
|
|
name, Device, name, AbCipDataType.Structure,
|
|
Members: [.. Bits.Select(n => new AbCipStructureMember(n, AbCipDataType.Bool)),
|
|
.. Sevs.Select(n => new AbCipStructureMember(n, AbCipDataType.DInt))]);
|
|
|
|
private static AbCipTagDefinition AlmdTag(string name) => new(
|
|
name, Device, name, AbCipDataType.Structure,
|
|
Members: [new AbCipStructureMember("InAlarm", AbCipDataType.Bool), new AbCipStructureMember("Acked", AbCipDataType.Bool),
|
|
new AbCipStructureMember("Severity", AbCipDataType.DInt)]);
|
|
|
|
private static AbCipDriverOptions Options(params AbCipTagDefinition[] tags) => new()
|
|
{
|
|
Devices = [new AbCipDeviceOptions(Device)],
|
|
RawTags = AbCipRawTags.From(tags),
|
|
EnableAlarmProjection = true,
|
|
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
|
};
|
|
|
|
private sealed record Rig(AbCipDriver Drv, FakeAbCipTagFactory Factory, List<AlarmEventArgs> Events, IAlarmSubscriptionHandle Handle)
|
|
{
|
|
public void Set(string member, object v, string tag = Tag) => Factory.Tags[$"{tag}.{member}"].Value = v;
|
|
public FakeAbCipTag Probe => Factory.Tags[$"{Tag}.HHInAlarm"];
|
|
public async Task StopAsync()
|
|
{
|
|
await Drv.UnsubscribeAlarmsAsync(Handle, CancellationToken.None);
|
|
await Drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
/// <summary>Starts a quiet ALMA (severities set, nothing in alarm, nothing acked) and applies <paramref name="seed"/>.</summary>
|
|
private static async Task<Rig> StartAsync(Action<Rig>? seed = null)
|
|
{
|
|
var factory = new FakeAbCipTagFactory();
|
|
var drv = new AbCipDriver(Options(AlmaTag()), "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([Tag], CancellationToken.None);
|
|
var rig = new Rig(drv, factory, events, handle);
|
|
await WaitFor(() => Bits.Take(10).Concat(Sevs).All(m => factory.Tags.ContainsKey($"{Tag}.{m}")));
|
|
foreach (var b in Bits.Take(10)) rig.Set(b, false);
|
|
rig.Set("HHSeverity", 900); rig.Set("HSeverity", 600); rig.Set("LSeverity", 300);
|
|
rig.Set("LLSeverity", 800); rig.Set("ROCPosSeverity", 400);
|
|
seed?.Invoke(rig);
|
|
return rig;
|
|
}
|
|
|
|
private static async Task WaitFor(Func<bool> cond)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
|
while (DateTime.UtcNow < deadline) { if (cond()) return; await Task.Delay(5); }
|
|
throw new TimeoutException("condition never met");
|
|
}
|
|
|
|
private static async Task<AlarmEventArgs> Next(Rig r, Func<AlarmEventArgs, bool> match)
|
|
{
|
|
await WaitFor(() => { lock (r.Events) return r.Events.Any(match); });
|
|
lock (r.Events) return r.Events.First(match);
|
|
}
|
|
|
|
/// <summary>Processed-count wait: six more polls of the shared member runtime.</summary>
|
|
private static async Task Settled(Rig r)
|
|
{
|
|
var target = r.Probe.ReadCount + 6;
|
|
await WaitFor(() => r.Probe.ReadCount >= target);
|
|
}
|
|
|
|
private static List<AlarmEventArgs> Snapshot(Rig r) { lock (r.Events) return [.. r.Events]; }
|
|
|
|
/// <summary>Detector: ALMA by per-limit bits and no plain Acked; ALMD-shaped / plain UDTs are not ALMA.</summary>
|
|
[Fact]
|
|
public void Detector_Flags_Alma_And_Not_Almd_Or_User_Udts()
|
|
{
|
|
AbCipAlarmDetector.IsAlma(AlmaTag()).ShouldBeTrue();
|
|
AbCipAlarmDetector.IsAlma(AlmdTag("A")).ShouldBeFalse();
|
|
AbCipAlarmDetector.IsAlmd(AlmdTag("A")).ShouldBeTrue();
|
|
// A user UDT that is ALMD-shaped but happens to name an "HInAlarm" stays ALMD.
|
|
var hybrid = new AbCipTagDefinition("U", Device, "U", AbCipDataType.Structure, Members:
|
|
[new AbCipStructureMember("InAlarm", AbCipDataType.Bool), new AbCipStructureMember("Acked", AbCipDataType.Bool),
|
|
new AbCipStructureMember("HInAlarm", AbCipDataType.Bool)]);
|
|
AbCipAlarmDetector.IsAlma(hybrid).ShouldBeFalse();
|
|
AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt)).ShouldBeFalse();
|
|
// An ALMA authored WITHOUT members cannot be told apart from an ALMD: it falls to the ALMD path.
|
|
AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("N", Device, "N", AbCipDataType.Structure)).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>A limit entering alarm raises one ALMA condition keyed on the RawPath, naming the limit, with the limit's severity.</summary>
|
|
[Fact]
|
|
public async Task A_limit_in_alarm_raises_with_the_limits_name_and_severity()
|
|
{
|
|
var r = await StartAsync();
|
|
await Settled(r);
|
|
r.Set("HInAlarm", true);
|
|
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
await r.StopAsync();
|
|
e.ConditionId.ShouldBe(Tag);
|
|
e.SourceNodeId.ShouldBe(Tag);
|
|
e.AlarmType.ShouldBe("ALMA");
|
|
e.Message.ShouldBe($"ALMA {Tag} H alarm");
|
|
e.OpcUaSeverity.ShouldBe(600);
|
|
e.Severity.ShouldBe(AlarmSeverity.High);
|
|
}
|
|
|
|
/// <summary>With several limits active the most severe one names the condition and sets its severity.</summary>
|
|
[Fact]
|
|
public async Task The_most_severe_active_limit_wins()
|
|
{
|
|
var r = await StartAsync(x => { x.Set("LInAlarm", true); x.Set("HHInAlarm", true); x.Set("ROCPosInAlarm", true); });
|
|
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
await r.StopAsync();
|
|
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
|
|
e.OpcUaSeverity.ShouldBe(900);
|
|
}
|
|
|
|
/// <summary>ROC limits are named with their sign.</summary>
|
|
[Fact]
|
|
public async Task A_rate_of_change_limit_is_named_with_its_sign()
|
|
{
|
|
var r = await StartAsync(x => x.Set("ROCPosInAlarm", true));
|
|
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
await r.StopAsync();
|
|
e.Message.ShouldBe($"ALMA {Tag} ROC+ alarm");
|
|
}
|
|
|
|
/// <summary>All limits returning to normal clears the one condition.</summary>
|
|
[Fact]
|
|
public async Task All_limits_clearing_fires_one_clear()
|
|
{
|
|
var r = await StartAsync(x => x.Set("LLInAlarm", true));
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
r.Set("LLInAlarm", false);
|
|
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Clear);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
e.ConditionId.ShouldBe(Tag);
|
|
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Clear).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>A newly entered (unacked) limit while active is a Retrigger, not a second Raise.</summary>
|
|
[Fact]
|
|
public async Task A_newly_entered_limit_while_active_retriggers()
|
|
{
|
|
var r = await StartAsync(x => x.Set("HInAlarm", true));
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
r.Set("HHInAlarm", true);
|
|
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
|
|
e.OpcUaSeverity.ShouldBe(900);
|
|
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>L1 — a limit entering ALREADY acked (AckRequired false) is atomic ActiveAcknowledged, never Retrigger+Acknowledge.</summary>
|
|
[Fact]
|
|
public async Task A_new_limit_entering_already_acked_is_active_acknowledged_not_retrigger_then_ack()
|
|
{
|
|
var r = await StartAsync(x => { x.Set("HInAlarm", true);
|
|
foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true); });
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.ActiveAcknowledged);
|
|
r.Set("HHInAlarm", true); // AckRequired=false: HHAcked is already true
|
|
await WaitFor(() => Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.ActiveAcknowledged) >= 2);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
var evs = Snapshot(r);
|
|
evs.Any(x => x.Kind is AlarmTransitionKind.Retrigger or AlarmTransitionKind.Acknowledge).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>M1 — a limit dropping out while another stays active moves the text/severity as a no-row state update.</summary>
|
|
[Fact]
|
|
public async Task A_limit_dropping_out_is_a_restatement_state_update()
|
|
{
|
|
var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HHInAlarm", true); });
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
r.Set("HHInAlarm", false);
|
|
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Unspecified);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
e.IsRestatement.ShouldBeTrue("the host must write no alerts row for it");
|
|
e.Message.ShouldBe($"ALMA {Tag} H alarm");
|
|
e.OpcUaSeverity.ShouldBe(600);
|
|
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Unspecified).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>M1 — a non-governing limit flickering (ROC under HH) changes neither text nor severity: no event at all.</summary>
|
|
[Fact]
|
|
public async Task A_non_governing_limit_flicker_emits_nothing()
|
|
{
|
|
var r = await StartAsync(x => { x.Set("HHInAlarm", true); x.Set("ROCPosInAlarm", true); });
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
r.Set("ROCPosInAlarm", false);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
Snapshot(r).Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>The PLC acknowledging every declared limit surfaces as one Acknowledge.</summary>
|
|
[Fact]
|
|
public async Task The_plc_acknowledging_the_limits_fires_acknowledge()
|
|
{
|
|
var r = await StartAsync(x => x.Set("HInAlarm", true));
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
r.Set("HAcked", true); r.Set("HHAcked", true); r.Set("LAcked", true); r.Set("LLAcked", true); r.Set("ROCPosAcked", true);
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Acknowledge);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// M2 — H acked; HH enters unacked then returns to normal still unacked (HHAcked stays false). The surviving,
|
|
/// acked H must NOT turn into a device "Acknowledged" for an HH excursion nobody acknowledged.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_unacked_excursion_that_returns_to_normal_is_not_reported_as_acknowledged()
|
|
{
|
|
var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HAcked", true); });
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise); // other limits' Acked bits are false ⇒ still unacked
|
|
r.Set("LAcked", true); r.Set("LLAcked", true); r.Set("ROCPosAcked", true); r.Set("HHAcked", true);
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Acknowledge);
|
|
r.Set("HHAcked", false); // HH occurrence (unacked)…
|
|
r.Set("HHInAlarm", true);
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
|
|
r.Set("HHInAlarm", false); // …returns to normal, still unacked
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1,
|
|
"only the genuine PLC ack — the unacked HH excursion must keep the condition unacknowledged");
|
|
}
|
|
|
|
/// <summary>F3 — fully acked, then a declared *Acked goes false with the SAME limits active (a limit that left and re-entered
|
|
/// between polls): the PLC says unacknowledged, so it is told as a Retrigger.</summary>
|
|
[Fact]
|
|
public async Task An_ack_falling_with_an_unchanged_active_set_retriggers()
|
|
{
|
|
var r = await StartAsync(x =>
|
|
{
|
|
x.Set("HInAlarm", true);
|
|
foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true);
|
|
});
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.ActiveAcknowledged);
|
|
r.Set("HAcked", false);
|
|
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
e.Message.ShouldBe($"ALMA {Tag} H alarm");
|
|
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Retrigger).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>An alarm already active AND acked when first seen is told atomically, never Raise-then-Acknowledge.</summary>
|
|
[Fact]
|
|
public async Task An_already_acked_active_alarm_is_active_acknowledged()
|
|
{
|
|
var r = await StartAsync(x =>
|
|
{
|
|
x.Set("LInAlarm", true);
|
|
foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true);
|
|
});
|
|
var e = await Next(r, x => x.Kind != AlarmTransitionKind.Unspecified);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
e.Kind.ShouldBe(AlarmTransitionKind.ActiveAcknowledged);
|
|
Snapshot(r).Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>A re-subscribe must not re-raise a still-active ALMA (it would un-acknowledge it).</summary>
|
|
[Fact]
|
|
public async Task A_resubscribe_does_not_re_raise_a_still_active_alma()
|
|
{
|
|
var r = await StartAsync(x => x.Set("HInAlarm", true));
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
var sub2 = await r.Drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
|
|
await Settled(r);
|
|
await r.Drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
|
await r.StopAsync();
|
|
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>A Bad limit read judges nothing that tick: no event, and no false Clear for an active alarm.</summary>
|
|
[Fact]
|
|
public async Task A_bad_read_does_not_clear_an_active_alarm()
|
|
{
|
|
var r = await StartAsync(x => x.Set("HInAlarm", true));
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
|
|
// every re-created runtime must fail too (a fresh fake would read as "all clear").
|
|
var recreations = 0;
|
|
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
|
|
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
|
|
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
|
|
await r.StopAsync();
|
|
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>L2 — a Bad <c>*Acked</c> read is "unknown", not "un-acked": it must neither un-ack nor re-ack.</summary>
|
|
[Fact]
|
|
public async Task A_bad_acked_read_is_carried_not_stored_as_unacked()
|
|
{
|
|
var r = await StartAsync(x =>
|
|
{
|
|
x.Set("HInAlarm", true);
|
|
foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true);
|
|
});
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.ActiveAcknowledged);
|
|
// Transient Bad *Acked reads: the evicted runtime is re-created failing, tick after tick.
|
|
var bad = 0;
|
|
r.Factory.Customise = p =>
|
|
{
|
|
if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!;
|
|
Interlocked.Increment(ref bad);
|
|
return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
|
|
};
|
|
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound;
|
|
await WaitFor(() => Volatile.Read(ref bad) >= 4);
|
|
// The link recovers and HAcked reads true again.
|
|
r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true };
|
|
await WaitFor(() => r.Factory.Tags[$"{Tag}.HAcked"].Status == 0 && r.Factory.Tags[$"{Tag}.HAcked"].Value is true);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
var evs = Snapshot(r);
|
|
evs.Any(x => x.Kind is AlarmTransitionKind.Acknowledge or AlarmTransitionKind.Retrigger or AlarmTransitionKind.Raise).ShouldBeFalse(
|
|
"a transient Bad *Acked read must not produce a second ack/raise");
|
|
}
|
|
|
|
/// <summary>L2 — a Bad severity read must not change the told message/severity.</summary>
|
|
[Fact]
|
|
public async Task A_bad_severity_read_changes_nothing()
|
|
{
|
|
var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HHInAlarm", true); });
|
|
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
|
|
r.Factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
|
|
r.Factory.Tags[$"{Tag}.HHSeverity"].Status = (int)AbStatus.NotFound;
|
|
await Task.Delay(150);
|
|
await Settled(r);
|
|
await r.StopAsync();
|
|
Snapshot(r).Count.ShouldBe(1, "no Retrigger/update from a Bad severity read");
|
|
}
|
|
|
|
/// <summary>A client acknowledge writes the ALMA's OperAckAll input — never the ALMD-only <c>Acked</c> member.</summary>
|
|
[Fact]
|
|
public async Task Acknowledge_writes_OperAckAll_not_Acked()
|
|
{
|
|
var r = await StartAsync();
|
|
await r.Drv.AcknowledgeAsync([new AlarmAcknowledgeRequest(Tag, Tag, null)], CancellationToken.None);
|
|
await r.StopAsync();
|
|
r.Factory.Tags.ShouldContainKey($"{Tag}.OperAckAll");
|
|
r.Factory.Tags[$"{Tag}.OperAckAll"].WriteCount.ShouldBe(1);
|
|
r.Factory.Tags.ShouldNotContainKey($"{Tag}.Acked");
|
|
}
|
|
|
|
/// <summary>L4 — one subscription mixing an ALMD and an ALMA: the cursor arithmetic keeps each node's reads apart.</summary>
|
|
[Fact]
|
|
public async Task A_mixed_almd_and_alma_subscription_projects_each_correctly()
|
|
{
|
|
var factory = new FakeAbCipTagFactory();
|
|
var drv = new AbCipDriver(Options(AlmdTag("Motor"), AlmaTag(), AlmdTag("Pump")), "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(["Motor", Tag, "Pump"], CancellationToken.None);
|
|
await WaitFor(() => factory.Tags.ContainsKey($"{Tag}.HHSeverity") && factory.Tags.ContainsKey("Pump.Severity")
|
|
&& factory.Tags.ContainsKey("Motor.Severity") && factory.Tags.ContainsKey($"{Tag}.HAcked"));
|
|
factory.Tags["Motor.Severity"].Value = 300;
|
|
factory.Tags["Pump.Severity"].Value = 700;
|
|
factory.Tags[$"{Tag}.HSeverity"].Value = 600;
|
|
factory.Tags["Pump.InAlarm"].Value = true;
|
|
factory.Tags[$"{Tag}.HInAlarm"].Value = true;
|
|
|
|
await WaitFor(() => { lock (events) return events.Count >= 2; });
|
|
await Task.Delay(100);
|
|
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
List<AlarmEventArgs> evs; lock (events) evs = [.. events];
|
|
evs.Count.ShouldBe(2);
|
|
var pump = evs.Single(e => e.ConditionId == "Pump");
|
|
pump.AlarmType.ShouldBe("ALMD"); pump.Severity.ShouldBe(AlarmSeverity.High);
|
|
var alma = evs.Single(e => e.ConditionId == Tag);
|
|
alma.AlarmType.ShouldBe("ALMA"); alma.OpcUaSeverity.ShouldBe(600);
|
|
}
|
|
}
|