Auto: twincat-5.1 — IAlarmSource via TC3 EventLogger (gated, scaffold)
Closes #316
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — covers the <see cref="IAlarmSource"/> shape on
|
||||
/// <see cref="TwinCATDriver"/>: feature-gating, gate event projection, multi-event
|
||||
/// ordering, acknowledge round-trip, and JSON DTO round-trip on the options.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATAlarmSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnableAlarms_false_does_not_create_alarm_source()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = false,
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.HasAlarmSource.ShouldBeFalse();
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
handle.ShouldBeOfType<TwinCATAlarmSubscriptionHandle>();
|
||||
((TwinCATAlarmSubscriptionHandle)handle).Id.ShouldBe(0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableAlarms_false_OnAlarmEvent_never_fires()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = false,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
// Even if a stray event is fired through the gate (a buggy operator wired in a
|
||||
// fake), the disabled-mode driver doesn't subscribe + the event is dropped.
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("Class.A", "Source1", 100, "msg", DateTimeOffset.UtcNow, false));
|
||||
await Task.Delay(20);
|
||||
|
||||
raised.ShouldBeEmpty();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableAlarms_true_creates_source_and_starts_gate_on_first_subscribe()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.HasAlarmSource.ShouldBeTrue();
|
||||
|
||||
gate.StartCount.ShouldBe(0);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
gate.StartCount.ShouldBe(1);
|
||||
|
||||
// Second subscribe doesn't restart the gate.
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
gate.StartCount.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_event_raises_AlarmEvent_on_driver_with_correct_shape()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
var stamp = DateTimeOffset.UtcNow;
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent(
|
||||
EventClass: "TcEventClass.MachineFault",
|
||||
Source: "Conveyor1.MotorOverload",
|
||||
Severity: 200,
|
||||
Message: "Motor overload tripped",
|
||||
OccurrenceUtc: stamp,
|
||||
Acked: false));
|
||||
|
||||
raised.Count.ShouldBe(1);
|
||||
var args = raised.First();
|
||||
args.SourceNodeId.ShouldBe("Conveyor1.MotorOverload");
|
||||
args.AlarmType.ShouldBe("TcEventClass.MachineFault");
|
||||
args.Message.ShouldBe("Motor overload tripped");
|
||||
args.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
args.SourceTimestampUtc.ShouldBe(stamp.UtcDateTime);
|
||||
args.ConditionId.ShouldBe("Conveyor1.MotorOverload#TcEventClass.MachineFault");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_alarm_events_are_delivered_in_order()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (raised) raised.Add(e); };
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
var t = DateTimeOffset.UtcNow;
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent(
|
||||
"Class.X", $"Source{i}", (ushort)(50 + i * 10), $"msg{i}", t.AddMilliseconds(i), false));
|
||||
}
|
||||
|
||||
raised.Count.ShouldBe(5);
|
||||
for (var i = 0; i < 5; i++)
|
||||
raised[i].SourceNodeId.ShouldBe($"Source{i}");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceFilter_only_passes_matching_source()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
_ = await drv.SubscribeAlarmsAsync(["Conveyor1"], CancellationToken.None);
|
||||
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "Conveyor1", 100, "x", DateTimeOffset.UtcNow, false));
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "OtherSource", 100, "y", DateTimeOffset.UtcNow, false));
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "conveyor1", 100, "z", DateTimeOffset.UtcNow, false)); // case-insensitive
|
||||
|
||||
raised.Count.ShouldBe(2);
|
||||
raised.ShouldAllBe(e => string.Equals(e.SourceNodeId, "Conveyor1", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_round_trips_to_gate()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("Conveyor1", "cond-1", "operator A")],
|
||||
CancellationToken.None);
|
||||
|
||||
gate.AckLog.Count.ShouldBe(1);
|
||||
gate.AckLog.Single().SourceNodeId.ShouldBe("Conveyor1");
|
||||
gate.AckLog.Single().ConditionId.ShouldBe("cond-1");
|
||||
gate.AckLog.Single().Comment.ShouldBe("operator A");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_when_disabled_is_noop()
|
||||
{
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = false,
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Should complete without throwing even though no source is wired.
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("X", "Y", null)], CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(new TwinCATAlarmSubscriptionHandle(0), CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_event_delivery()
|
||||
{
|
||||
var gate = new FakeTwinCATAlarmGate();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableAlarms = true,
|
||||
}, "drv-1", alarmGate: gate);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||
var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "before", DateTimeOffset.UtcNow, false));
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "after", DateTimeOffset.UtcNow, false));
|
||||
|
||||
raised.Count.ShouldBe(1);
|
||||
raised.First().Message.ShouldBe("before");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_mapping_buckets_match_quartile_cuts()
|
||||
{
|
||||
TwinCATAlarmSource.MapSeverity(0).ShouldBe(AlarmSeverity.Low);
|
||||
TwinCATAlarmSource.MapSeverity(64).ShouldBe(AlarmSeverity.Low);
|
||||
TwinCATAlarmSource.MapSeverity(65).ShouldBe(AlarmSeverity.Medium);
|
||||
TwinCATAlarmSource.MapSeverity(128).ShouldBe(AlarmSeverity.Medium);
|
||||
TwinCATAlarmSource.MapSeverity(129).ShouldBe(AlarmSeverity.High);
|
||||
TwinCATAlarmSource.MapSeverity(192).ShouldBe(AlarmSeverity.High);
|
||||
TwinCATAlarmSource.MapSeverity(193).ShouldBe(AlarmSeverity.Critical);
|
||||
TwinCATAlarmSource.MapSeverity(255).ShouldBe(AlarmSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_round_trip_preserves_EnableAlarms()
|
||||
{
|
||||
var original = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
|
||||
EnableAlarms = true,
|
||||
};
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var restored = JsonSerializer.Deserialize<TwinCATDriverOptions>(json);
|
||||
|
||||
restored.ShouldNotBeNull();
|
||||
restored.EnableAlarms.ShouldBeTrue();
|
||||
|
||||
var defaultRestored = JsonSerializer.Deserialize<TwinCATDriverOptions>("{}");
|
||||
defaultRestored.ShouldNotBeNull();
|
||||
defaultRestored.EnableAlarms.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake alarm gate — captures Start invocations + ack requests, exposes
|
||||
/// <see cref="RaiseAlarm"/> so tests can drive synthetic events without standing up
|
||||
/// a second AMS-port-110 session against a real TC3 EventLogger.
|
||||
/// </summary>
|
||||
private sealed class FakeTwinCATAlarmGate : ITwinCATAlarmGate
|
||||
{
|
||||
public int StartCount { get; private set; }
|
||||
public List<AlarmAcknowledgeRequest> AckLog { get; } = new();
|
||||
public List<TwinCATAlarmEvent> ActiveAlarmsList { get; } = new();
|
||||
public IReadOnlyList<TwinCATAlarmEvent> ActiveAlarms => ActiveAlarmsList;
|
||||
|
||||
public event EventHandler<TwinCATAlarmEvent>? OnAlarmEvent;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StartCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AckLog.AddRange(acknowledgements);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RaiseAlarm(TwinCATAlarmEvent evt) => OnAlarmEvent?.Invoke(this, evt);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user