Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Alarms/AlarmConditionServiceTests.cs
Joseph Doherty ef22a61c39 v2 mxgw migration — Phase 1+2+3.1 wiring (7 PRs)
Foundational PRs from lmx_mxgw_impl.md, all green. Bodies only — DI/wiring
deferred to PR 1+2.W (combined wire-up) and PR 3.W.

PR 1.1 — IHistorianDataSource lifted to Core.Abstractions/Historian/
  Reuses existing DataValueSnapshot + HistoricalEvent shapes; sidecar (PR
  3.4) translates byte-quality → uint StatusCode internally.

PR 1.2 — IHistoryRouter + HistoryRouter on the server
  Longest-prefix-match resolution, case-insensitive, ObjectDisposed-guarded,
  swallow-on-shutdown disposal of misbehaving sources.

PR 1.3 — DriverNodeManager.HistoryRead* dispatch through IHistoryRouter
  Per-tag resolution with LegacyDriverHistoryAdapter wrapping
  `_driver as IHistoryProvider` so existing tests + drivers keep working
  until PR 7.2 retires the fallback.

PR 2.1 — AlarmConditionInfo extended with five sub-attribute refs
  InAlarmRef / PriorityRef / DescAttrNameRef / AckedRef / AckMsgWriteRef.
  Optional defaulted parameters preserve all existing 3-arg call sites.

PR 2.2 — AlarmConditionService state machine in Server/Alarms/
  Driver-agnostic port of GalaxyAlarmTracker. Sub-attribute refs come from
  AlarmConditionInfo, values arrive as DataValueSnapshot, ack writes route
  through IAlarmAcknowledger. State machine preserves Active/Acknowledged/
  Inactive transitions, Acked-on-active reset, post-disposal silence.

PR 2.3 — DriverNodeManager wires AlarmConditionService
  MarkAsAlarmCondition registers each alarm-bearing variable with the
  service; DriverWritableAcknowledger routes ack-message writes through
  the driver's IWritable + CapabilityInvoker. Service-raised transitions
  route via OnAlarmServiceTransition → matching ConditionSink. Legacy
  IAlarmSource path unchanged for null service.

PR 3.1 — Driver.Historian.Wonderware shell project (net48 x86)
  Console host shell + smoke test; SDK references + code lift come in
  PR 3.2.

Tests: 9 (PR 1.1) + 5 (PR 2.1) + 10 (PR 1.2) + 19 (PR 2.2) + 1 (PR 3.1)
all pass. Existing AlarmSubscribeIntegrationTests + HistoryReadIntegrationTests
unchanged.

Plan + audit docs (lmx_backend.md, lmx_mxgw.md, lmx_mxgw_impl.md)
included so parallel subagent worktrees can read them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:03:36 -04:00

332 lines
11 KiB
C#

using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
/// <summary>
/// Server-level alarm-condition state-machine tests added in PR 2.2. Ports the live
/// transition cases from <c>GalaxyAlarmTrackerTests</c> against the new
/// driver-agnostic <see cref="AlarmConditionService"/>: sub-attribute references come
/// from <see cref="AlarmConditionInfo"/>, value changes flow as
/// <see cref="DataValueSnapshot"/> instead of MX-specific <c>Vtq</c>, and the ack
/// write path is decoupled into <see cref="IAlarmAcknowledger"/>.
/// </summary>
public sealed class AlarmConditionServiceTests
{
private const string ConditionId = "TankFarm.Tank1.Level.HiHi";
private const string InAlarmRef = "TankFarm.Tank1.Level.HiHi.InAlarm";
private const string PriorityRef = "TankFarm.Tank1.Level.HiHi.Priority";
private const string DescRef = "TankFarm.Tank1.Level.HiHi.DescAttrName";
private const string AckedRef = "TankFarm.Tank1.Level.HiHi.Acked";
private const string AckMsgWriteRef = "TankFarm.Tank1.Level.HiHi.AckMsg";
private static AlarmConditionInfo Info(
string? inAlarm = InAlarmRef, string? priority = PriorityRef,
string? desc = DescRef, string? acked = AckedRef, string? ackMsg = AckMsgWriteRef)
=> new(
SourceName: ConditionId,
InitialSeverity: AlarmSeverity.Medium,
InitialDescription: null,
InAlarmRef: inAlarm,
PriorityRef: priority,
DescAttrNameRef: desc,
AckedRef: acked,
AckMsgWriteRef: ackMsg);
private static DataValueSnapshot Bool(bool v) =>
new(v, StatusCode: 0, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow);
private static DataValueSnapshot Int(int v) =>
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
private static DataValueSnapshot Str(string v) =>
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
private sealed class FakeAcker : IAlarmAcknowledger
{
public readonly ConcurrentQueue<(string Ref, string Comment)> Writes = new();
public bool ReturnValue { get; set; } = true;
public Task<bool> WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
{
Writes.Enqueue((ackMsgWriteRef, comment));
return Task.FromResult(ReturnValue);
}
}
[Fact]
public void Track_AddsCondition_AndExposesSubscribedReferences()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.TrackedCount.ShouldBe(1);
var refs = svc.GetSubscribedReferences();
refs.ShouldContain(InAlarmRef);
refs.ShouldContain(PriorityRef);
refs.ShouldContain(DescRef);
refs.ShouldContain(AckedRef);
refs.Count.ShouldBe(4);
}
[Fact]
public void Track_IsIdempotentOnRepeatCall()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.Track(ConditionId, Info());
svc.TrackedCount.ShouldBe(1);
}
[Fact]
public void Track_OmitsNullSubAttributeRefs()
{
using var svc = new AlarmConditionService();
// Driver may not expose every sub-attribute (e.g. no .Acked observable).
svc.Track(ConditionId, Info(priority: null, desc: null, acked: null));
svc.GetSubscribedReferences().ShouldBe(new[] { InAlarmRef });
}
[Fact]
public void InAlarmFalseToTrue_FiresActiveTransition()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
svc.OnValueChanged(PriorityRef, Int(500));
svc.OnValueChanged(DescRef, Str("Tank level high-high"));
svc.OnValueChanged(InAlarmRef, Bool(true));
transitions.Count.ShouldBe(1);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.Transition.ShouldBe(AlarmStateTransition.Active);
t.Priority.ShouldBe(500);
t.Description.ShouldBe("Tank level high-high");
t.ConditionId.ShouldBe(ConditionId);
}
[Fact]
public void InAlarmTrueToFalse_FiresInactiveTransition()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
svc.OnValueChanged(InAlarmRef, Bool(true));
svc.OnValueChanged(InAlarmRef, Bool(false));
transitions.Count.ShouldBe(2);
transitions.TryDequeue(out _);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.Transition.ShouldBe(AlarmStateTransition.Inactive);
}
[Fact]
public void AckedFalseToTrue_FiresAcknowledged_WhileActive()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
svc.OnValueChanged(InAlarmRef, Bool(true)); // Active, resets Acked → false
svc.OnValueChanged(AckedRef, Bool(true)); // Acknowledged
transitions.Count.ShouldBe(2);
transitions.TryDequeue(out _);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.Transition.ShouldBe(AlarmStateTransition.Acknowledged);
}
[Fact]
public void AckedTransitionWhileInactive_DoesNotFire()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
// Initial Acked=true on subscribe (alarm at rest, pre-ack'd) — must not fire.
svc.OnValueChanged(AckedRef, Bool(true));
transitions.ShouldBeEmpty();
}
[Fact]
public void RepeatedActiveTransitions_ResetAckedFlag()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
// Cycle 1: active → ack → inactive → active again
svc.OnValueChanged(InAlarmRef, Bool(true));
svc.OnValueChanged(AckedRef, Bool(true));
svc.OnValueChanged(InAlarmRef, Bool(false));
svc.OnValueChanged(InAlarmRef, Bool(true)); // re-arms — Acked must reset to false
svc.OnValueChanged(AckedRef, Bool(true)); // produces a fresh Acknowledged
// Active, Acknowledged, Inactive, Active, Acknowledged
transitions.Count.ShouldBe(5);
var ordered = transitions.Select(t => t.Transition).ToArray();
ordered.ShouldBe(new[]
{
AlarmStateTransition.Active,
AlarmStateTransition.Acknowledged,
AlarmStateTransition.Inactive,
AlarmStateTransition.Active,
AlarmStateTransition.Acknowledged,
});
}
[Fact]
public async Task AcknowledgeAsync_RoutesToAckerWithAckMsgRef()
{
using var svc = new AlarmConditionService();
var acker = new FakeAcker();
svc.Track(ConditionId, Info(), acker);
var ok = await svc.AcknowledgeAsync(ConditionId, "operator-1: cleared", CancellationToken.None);
ok.ShouldBeTrue();
acker.Writes.Count.ShouldBe(1);
acker.Writes.TryDequeue(out var w).ShouldBeTrue();
w.Ref.ShouldBe(AckMsgWriteRef);
w.Comment.ShouldBe("operator-1: cleared");
}
[Fact]
public async Task AcknowledgeAsync_ReturnsFalse_WhenConditionUntracked()
{
using var svc = new AlarmConditionService();
var acker = new FakeAcker();
svc.Track("OtherCondition", Info(), acker);
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
ok.ShouldBeFalse();
acker.Writes.ShouldBeEmpty();
}
[Fact]
public async Task AcknowledgeAsync_ReturnsFalse_WhenNoAckerRegistered()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info(), acker: null);
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
ok.ShouldBeFalse();
}
[Fact]
public async Task AcknowledgeAsync_ReturnsFalse_WhenAckMsgRefMissing()
{
using var svc = new AlarmConditionService();
var acker = new FakeAcker();
svc.Track(ConditionId, Info(ackMsg: null), acker);
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
ok.ShouldBeFalse();
acker.Writes.ShouldBeEmpty();
}
[Fact]
public void Snapshot_ReportsLatestFields()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.OnValueChanged(InAlarmRef, Bool(true));
svc.OnValueChanged(PriorityRef, Int(900));
svc.OnValueChanged(DescRef, Str("MyAlarm"));
svc.OnValueChanged(AckedRef, Bool(true));
var snap = svc.Snapshot();
snap.Count.ShouldBe(1);
snap[0].ConditionId.ShouldBe(ConditionId);
snap[0].InAlarm.ShouldBeTrue();
snap[0].Acked.ShouldBeTrue();
snap[0].Priority.ShouldBe(900);
snap[0].Description.ShouldBe("MyAlarm");
}
[Fact]
public void OnValueChanged_ForUnknownReference_IsSilentlyIgnored()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.OnValueChanged("Some.Random.Tag.InAlarm", Bool(true));
transitions.ShouldBeEmpty();
}
[Fact]
public void Untrack_RemovesConditionAndReleasesReferences()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.Untrack(ConditionId);
svc.TrackedCount.ShouldBe(0);
svc.GetSubscribedReferences().ShouldBeEmpty();
}
[Fact]
public void Untrack_NonexistentConditionIsNoOp()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
Should.NotThrow(() => svc.Untrack("does-not-exist"));
svc.TrackedCount.ShouldBe(1);
}
[Fact]
public void Track_ThrowsAfterDisposal()
{
var svc = new AlarmConditionService();
svc.Dispose();
Should.Throw<ObjectDisposedException>(() => svc.Track(ConditionId, Info()));
}
[Fact]
public void OnValueChanged_AfterDisposal_IsSilentlyDropped()
{
var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.Dispose();
// Stale callbacks during disposal must not throw.
Should.NotThrow(() => svc.OnValueChanged(InAlarmRef, Bool(true)));
}
[Fact]
public void PriorityCoercion_AcceptsCommonNumericTypes()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.OnValueChanged(PriorityRef, new DataValueSnapshot((short)123, 0, null, DateTime.UtcNow));
svc.OnValueChanged(InAlarmRef, Bool(true));
var snap = svc.Snapshot()[0];
snap.Priority.ShouldBe(123);
}
}