8c5e2be92e
Layer 3 of #477: a scripted alarm's condition Quality now reflects the WORST quality across its input tags, mirroring the native OT semantic (#477 L2). Plumbing (quality was silently discarded twice on the live path): - VirtualTagActor.DependencyValueChanged gains Quality (defaulted Good); the DependencyMuxActor forwards the published AttributeValuePublished.Quality it already carried; ScriptedAlarmHostActor.OnDependencyChanged pushes the real quality into the engine (was hardcoded 0u/Good). Engine (Core.ScriptedAlarms): - ScriptedAlarmEngine computes worst-of-input quality each eval (skipping not-yet-published inputs, which are a readiness concern, not a quality signal) and carries it on ScriptedAlarmEvent.WorstInputStatusCode. - A real transition carries the current worst quality so ToSnapshot's full snapshot doesn't clobber quality back to Good (e.g. transition while Uncertain). - A Bad input freezes the condition (no transition), like a comms-lost native driver; a quality-bucket change with no transition emits the new EmissionKind.QualityChanged, routed to the existing #477-L2 AlarmQualityUpdate -> WriteAlarmQuality node path (quality only, no /alerts row, no historian write). ScriptedAlarmSource skips QualityChanged so it never fabricates a phantom IAlarmSource event. Host: ToSnapshot maps WorstInputStatusCode -> OpcUaQuality; OnEngineEmission routes QualityChanged out of band. Tests (TDD, RED-first): engine worst-carry + Bad/restore QualityChanged + unchanged-bucket-no-emit; source swallows QualityChanged; mux forwards quality; host Bad-dep -> AlarmQualityUpdate(no alerts) + transition snapshot carries worst. Docs: AlarmTracking.md Layer-3 section + design doc. Closes #478
201 lines
7.8 KiB
C#
201 lines
7.8 KiB
C#
using Serilog;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ScriptedAlarmSourceTests
|
|
{
|
|
private static async Task<(ScriptedAlarmEngine e, ScriptedAlarmSource s, FakeUpstream u)> BuildAsync()
|
|
{
|
|
var up = new FakeUpstream();
|
|
up.Set("Temp", 50);
|
|
var logger = new LoggerConfiguration().CreateLogger();
|
|
var engine = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
|
|
new ScriptLoggerFactory(logger), logger);
|
|
await engine.LoadAsync([
|
|
new ScriptedAlarmDefinition(
|
|
"Plant/Line1::HighTemp",
|
|
"Plant/Line1",
|
|
"HighTemp",
|
|
AlarmKind.LimitAlarm,
|
|
AlarmSeverity.High,
|
|
"Temp {Temp}C",
|
|
"""return (int)ctx.GetTag("Temp").Value > 100;"""),
|
|
new ScriptedAlarmDefinition(
|
|
"Plant/Line2::OtherAlarm",
|
|
"Plant/Line2",
|
|
"OtherAlarm",
|
|
AlarmKind.AlarmCondition,
|
|
AlarmSeverity.Low,
|
|
"other",
|
|
"""return false;"""),
|
|
], CancellationToken.None);
|
|
|
|
var source = new ScriptedAlarmSource(engine);
|
|
return (engine, source, up);
|
|
}
|
|
|
|
/// <summary>Verifies that subscribing with an empty filter receives every alarm emission.</summary>
|
|
[Fact]
|
|
public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
|
|
{
|
|
var (engine, source, up) = await BuildAsync();
|
|
using var _e = engine;
|
|
using var _s = source;
|
|
|
|
var events = new List<AlarmEventArgs>();
|
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
|
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
|
|
|
up.Push("Temp", 150);
|
|
await Task.Delay(200);
|
|
|
|
events.Count.ShouldBe(1);
|
|
events[0].ConditionId.ShouldBe("Plant/Line1::HighTemp");
|
|
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
|
events[0].Severity.ShouldBe(AlarmSeverity.High);
|
|
events[0].AlarmType.ShouldBe("LimitAlarm");
|
|
events[0].Message.ShouldBe("Temp 150C");
|
|
|
|
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>Verifies that subscribing with an equipment prefix filters alarms by that prefix.</summary>
|
|
[Fact]
|
|
public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
|
|
{
|
|
var (engine, source, up) = await BuildAsync();
|
|
using var _e = engine;
|
|
using var _s = source;
|
|
|
|
var events = new List<AlarmEventArgs>();
|
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
|
|
|
// Subscribe only to Line1 alarms.
|
|
var handle = await source.SubscribeAlarmsAsync(["Plant/Line1"], TestContext.Current.CancellationToken);
|
|
|
|
up.Push("Temp", 150);
|
|
await Task.Delay(200);
|
|
|
|
events.Count.ShouldBe(1);
|
|
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
|
|
|
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>Verifies that unsubscribing stops further alarm events.</summary>
|
|
[Fact]
|
|
public async Task Unsubscribe_stops_further_events()
|
|
{
|
|
var (engine, source, up) = await BuildAsync();
|
|
using var _e = engine;
|
|
using var _s = source;
|
|
|
|
var events = new List<AlarmEventArgs>();
|
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
|
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
|
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
|
|
|
up.Push("Temp", 150);
|
|
await Task.Delay(200);
|
|
|
|
events.Count.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>#478 — a QualityChanged engine emission (source-quality bucket change, no state transition)
|
|
/// must NOT surface through the IAlarmSource fan-out: quality is delivered out of band via the
|
|
/// dedicated node path, never as an AlarmEventArgs (which would materialize / historize a condition).</summary>
|
|
[Fact]
|
|
public async Task QualityChanged_emission_raises_no_alarm_event()
|
|
{
|
|
var (engine, source, up) = await BuildAsync();
|
|
using var _e = engine;
|
|
using var _s = source;
|
|
|
|
var events = new List<AlarmEventArgs>();
|
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
|
await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
|
|
|
// Drive HighTemp's input Bad: the predicate freezes (no transition), but the worst-quality bucket
|
|
// moves Good→Bad → the engine emits QualityChanged. The source must swallow it.
|
|
up.Push("Temp", 50, 0x80000000u);
|
|
await Task.Delay(200);
|
|
|
|
events.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
|
{
|
|
var (engine, source, up) = await BuildAsync();
|
|
using var _e = engine;
|
|
using var _s = source;
|
|
|
|
up.Push("Temp", 150);
|
|
await Task.Delay(200);
|
|
engine.GetState("Plant/Line1::HighTemp")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
|
|
|
await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
|
|
"Plant/Line1", "Plant/Line1::HighTemp", "ack via opcua")],
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var state = engine.GetState("Plant/Line1::HighTemp")!;
|
|
state.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
|
state.LastAckUser.ShouldBe("opcua-client");
|
|
state.LastAckComment.ShouldBe("ack via opcua");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that AcknowledgeAsync honors a supplied authenticated principal
|
|
/// (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>) and falls back to
|
|
/// <c>"opcua-client"</c> only when none is supplied.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ScriptedAlarmSource_uses_supplied_principal_else_default()
|
|
{
|
|
var (engine, source, up) = await BuildAsync();
|
|
using var _e = engine;
|
|
using var _s = source;
|
|
|
|
// Supplied principal -> honored.
|
|
up.Push("Temp", 150);
|
|
await Task.Delay(200);
|
|
await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
|
|
"Plant/Line1", "Plant/Line1::HighTemp", "ack as bob", "bob")],
|
|
TestContext.Current.CancellationToken);
|
|
engine.GetState("Plant/Line1::HighTemp")!.LastAckUser.ShouldBe("bob");
|
|
|
|
// No principal -> default audit identity.
|
|
up.Push("Temp", 50);
|
|
await Task.Delay(200);
|
|
up.Push("Temp", 150);
|
|
await Task.Delay(200);
|
|
await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
|
|
"Plant/Line1", "Plant/Line1::HighTemp", "ack anon", null)],
|
|
TestContext.Current.CancellationToken);
|
|
engine.GetState("Plant/Line1::HighTemp")!.LastAckUser.ShouldBe("opcua-client");
|
|
}
|
|
|
|
/// <summary>Verifies that null arguments are rejected.</summary>
|
|
[Fact]
|
|
public async Task Null_arguments_rejected()
|
|
{
|
|
var (engine, source, _) = await BuildAsync();
|
|
using var _e = engine;
|
|
using var _s = source;
|
|
|
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
|
await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
|
await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
|
await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken));
|
|
}
|
|
}
|