Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs
T
Joseph Doherty 8c5e2be92e
v2-ci / build (pull_request) Successful in 3m48s
v2-ci / unit-tests (pull_request) Failing after 11m0s
feat(alarms): scripted condition Quality from worst-of-input tag quality (#478)
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
2026-07-17 15:56:06 -04:00

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));
}
}