b61f3e0560
v2-ci / unit-tests (push) Has been cancelled
GalaxyAlarmPriority, the one table live Galaxy alarms (MxAccessSeverityMapper) and AVEVA A&E history (EventMapper, #886) map the AVEVA priority through, ranked a higher number as more severe. AVEVA ranks priority 1 as the most urgent, so a priority-1 alarm read 250 and a priority-999 system event 1000. It now uses AVEVA's default priority ranges, most urgent first: AVEVA priority AVEVA severity bucket OPC UA Severity 1-250 1 Critical Critical 1000 251-500 2 High High 750 501-750 3 Medium Medium 500 751-999 4 Low Low 250 (history only) 0 not an alarm - 1 Out-of-range priorities clamp (<= 0 Critical, > 999 Low). Evidence, vd03 Runtime.dbo.Events read-only: priority 1/100 alarms carry AVEVA severity 1, 400/500 severity 2, 600/700 severity 3, and every priority-999 System.* / User.* row severity 0 with IsAlarm 0. - Live and history now use the same number. GalaxyDriver passes the table's exact value as AlarmEventArgs.OpcUaSeverity. Before, it passed only the bucket, which NativeAlarmProjector re-mapped through its generic 200/500/700/900 ladder, so live and history disagreed even before the direction fix. - History: a row whose AVEVA severity is 0 (System.Start/Stop/Deploy, User.Write...) is not an alarm and reads Severity 1, below every alarm band. For alarms AVEVA's 1-4 severity is still never the value: the live feed carries only the priority, so it cannot use it, and history must agree. - Docs: docs/Historian.md gains "Galaxy alarm priority -> Severity (lmxopcua#887)"; docs/AlarmTracking.md updated. - Live re-run of the #801/#886 harness against vd03 HistorianGateway ea9f21c (read-only, network-only): 224 of 233 cases identical to the #886 run, the 9 others differ only in Severity (priority-700 alarm rows 750 -> 500, 19 system/user events 1000 -> 1). Record: aveva-history-801.md "#887 re-run", report aveva-gate-sdk2-887.json.gz. Client-visible: every OPC UA client that ranks or filters Galaxy alarms by Severity sees the order reversed (live and HistoryReadEvents). ScadaBridge has no code that depends on the old values; its own mixed severity directions are ScadaBridge#129. Tests updated because they pinned the inverted table: - Driver.Galaxy.Tests MxAccessSeverityMapperTests (rewritten: edges 1/250/251/ 500/501/750/751/999, 0/-100/int.MinValue/1000/int.MaxValue clamps, monotonic-decreasing sweep, equals GalaxyAlarmPriority for -2..1001) - Driver.Galaxy.Tests GatewayGalaxyAlarmFeedTests (750 and 600 -> Medium) - Driver.Historian.Gateway.Tests EventMapperTests (band table, priority 999 -> 250, 1 -> 1000; system events -> 1) - OpcUaServer.IntegrationTests GalaxyEquipmentEventHistoryWireTests (700 -> 500, 100 -> 1000, System.Stop -> 1; the fake now stores AVEVA's real 0-4 severity) New tests: - Runtime.Tests NativeAlarmRestatementEndToEndTests .Galaxy_live_condition_severity_is_the_AVEVA_priority_band (8 rows: the real feed -> real GalaxyDriver -> host -> projector; the same rows EventMapperTests pins on the history path) - EventMapperTests.Aveva_severity_zero_marks_an_event_that_is_not_an_alarm (5 rows, incl. the priority-999 alarm positive control) - GalaxyDriverAlarmSourceTests: OpcUaSeverity passes through Mutation check, 8 mutants, all killed: the pre-#887 inverted table; the driver dropping OpcUaSeverity; no not-an-alarm rule; each of the three band edges off by one; history ignoring priority; a missing AVEVA severity read as 0. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
336 lines
16 KiB
C#
336 lines
16 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins GalaxyDriver's <c>IAlarmSource</c> implementation. The driver bridges the
|
|
/// gateway's session-less alarm feed (<see cref="IGalaxyAlarmFeed"/>, production:
|
|
/// <c>GatewayGalaxyAlarmFeed</c>) onto <c>IAlarmSource.OnAlarmEvent</c> and forwards
|
|
/// Acknowledge through <see cref="IGalaxyAlarmAcknowledger"/> (production:
|
|
/// <c>GatewayGalaxyAlarmAcknowledger</c> calling the session-less
|
|
/// <c>AcknowledgeAlarm</c> RPC).
|
|
/// </summary>
|
|
public sealed class GalaxyDriverAlarmSourceTests
|
|
{
|
|
/// <summary>Verifies that SubscribeAlarmsAsync starts the alarm feed and events fire on transition.</summary>
|
|
[Fact]
|
|
public async Task SubscribeAlarmsAsync_starts_feed_and_event_fires_on_transition()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
handle.ShouldNotBeNull();
|
|
feed.Started.ShouldBeTrue("SubscribeAlarmsAsync must start the alarm feed");
|
|
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.Critical));
|
|
|
|
observed.ShouldHaveSingleItem();
|
|
observed[0].ConditionId.ShouldBe("Tank01.Level.HiHi");
|
|
observed[0].SourceNodeId.ShouldBe("Tank01");
|
|
observed[0].AlarmType.ShouldBe("AnalogLimitAlarm.HiHi");
|
|
observed[0].Severity.ShouldBe(AlarmSeverity.Critical);
|
|
// lmxopcua#887: the exact priority-table severity rides along, so the host doesn't re-map the bucket.
|
|
observed[0].OpcUaSeverity.ShouldBe(800);
|
|
observed[0].SubscriptionHandle.ShouldBe(handle);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the driver maps each Galaxy transition kind onto the matching
|
|
/// <see cref="AlarmTransitionKind"/> on the surfaced <see cref="AlarmEventArgs"/>,
|
|
/// so a Part 9 consumer can derive active/ack state. The Galaxy kind is passed by
|
|
/// name because <c>GalaxyAlarmTransitionKind</c> is internal to the driver and so
|
|
/// cannot appear in a public test method signature.
|
|
/// </summary>
|
|
/// <param name="galaxyKindName">The <c>GalaxyAlarmTransitionKind</c> member name fed into the alarm feed.</param>
|
|
/// <param name="expectedKind">The expected <see cref="AlarmTransitionKind"/> on the surfaced event.</param>
|
|
[Theory]
|
|
[InlineData(nameof(GalaxyAlarmTransitionKind.Raise), AlarmTransitionKind.Raise)]
|
|
[InlineData(nameof(GalaxyAlarmTransitionKind.Acknowledge), AlarmTransitionKind.Acknowledge)]
|
|
[InlineData(nameof(GalaxyAlarmTransitionKind.Clear), AlarmTransitionKind.Clear)]
|
|
[InlineData(nameof(GalaxyAlarmTransitionKind.Retrigger), AlarmTransitionKind.Retrigger)]
|
|
[InlineData(nameof(GalaxyAlarmTransitionKind.Unspecified), AlarmTransitionKind.Unspecified)]
|
|
public async Task Transition_kind_maps_onto_AlarmEventArgs_Kind(
|
|
string galaxyKindName, AlarmTransitionKind expectedKind)
|
|
{
|
|
var galaxyKind = Enum.Parse<GalaxyAlarmTransitionKind>(galaxyKindName);
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01", galaxyKind, AlarmSeverity.High));
|
|
|
|
observed.ShouldHaveSingleItem();
|
|
observed[0].Kind.ShouldBe(expectedKind);
|
|
}
|
|
|
|
/// <summary>Verifies that OnAlarmEvent does not fire before any alarm subscription.</summary>
|
|
[Fact]
|
|
public void OnAlarmEvent_does_not_fire_before_any_alarm_subscription()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
// No SubscribeAlarmsAsync → the feed is never wired onto the driver, so a
|
|
// transition surfaces nowhere.
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
|
|
|
|
observed.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies that UnsubscribeAlarmsAsync stops event flow.</summary>
|
|
[Fact]
|
|
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
|
|
|
// The feed keeps running (it is session-less and shared), but with no active
|
|
// subscription the driver suppresses the bridged event.
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
|
|
|
|
observed.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Regression for the session-less alarm-handle leak: the gateway's StreamAlarms feed survives an
|
|
/// in-place reconnect, so <c>DriverInstanceActor</c> re-subscribes on every Connected re-entry
|
|
/// (after dropping its own cached handle). The driver must COLLAPSE to a single live handle on each
|
|
/// subscribe rather than accumulate one per reconnect. Observable proof (the handle set is private):
|
|
/// after three re-subscribes, releasing only the LATEST handle fully gates the feed — which can only
|
|
/// happen if the earlier handles were not retained. Before the fix the set kept h1/h2/h3, so
|
|
/// releasing h3 left the gate open and a transition still surfaced.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Re_subscribe_collapses_to_a_single_handle_no_accumulation()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
// Simulate the actor re-subscribing on each in-place reconnect.
|
|
var h1 = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
var h2 = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
var latest = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
|
|
// Each subscribe issues a DISTINCT handle (not a reused singleton) — otherwise the collapse
|
|
// semantics would be vacuous.
|
|
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
|
|
h2.DiagnosticId.ShouldNotBe(latest.DiagnosticId);
|
|
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
// Releasing ONLY the latest handle must fully gate the feed — proving the driver held exactly one
|
|
// live handle, not the three issued across the simulated reconnects.
|
|
await driver.UnsubscribeAlarmsAsync(latest, CancellationToken.None);
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
|
|
observed.ShouldBeEmpty("re-subscribe must collapse to one handle; releasing it gates the feed");
|
|
|
|
// And the latest subscribe is the live one: a fresh subscribe re-opens the gate.
|
|
var reopened = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
|
|
observed.ShouldHaveSingleItem();
|
|
observed[0].SubscriptionHandle.ShouldBe(reopened);
|
|
}
|
|
|
|
/// <summary>Verifies that UnsubscribeAlarmsAsync throws for a foreign handle.</summary>
|
|
[Fact]
|
|
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var foreignHandle = new ForeignAlarmHandle();
|
|
await Should.ThrowAsync<ArgumentException>(() =>
|
|
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>Verifies that AcknowledgeAsync routes each request to the acknowledger.</summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var requests = new[]
|
|
{
|
|
new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", "shift handover"),
|
|
new AlarmAcknowledgeRequest("Tank02", "Tank02.Level.HiHi", "investigating"),
|
|
};
|
|
|
|
await driver.AcknowledgeAsync(requests, CancellationToken.None);
|
|
|
|
ack.Calls.Count.ShouldBe(2);
|
|
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
|
ack.Calls[0].Comment.ShouldBe("shift handover [OPC UA: anonymous]");
|
|
// lmxopcua#749: the gateway is sent the resolved Galaxy account, never an empty operator.
|
|
ack.Calls[0].Operator.ShouldBe("ack-op");
|
|
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
|
|
}
|
|
|
|
/// <summary>Verifies that AcknowledgeAsync falls back to SourceNodeId when ConditionId is empty.</summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
await driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("Tank01.Level.HiHi", string.Empty, null)],
|
|
CancellationToken.None);
|
|
|
|
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the request's OPC UA principal + key id (<see cref="AlarmAcknowledgeRequest.OperatorUser"/>,
|
|
/// <see cref="AlarmAcknowledgeRequest.ApiKeyId"/>) are recorded in the ack comment, and the key id is what
|
|
/// the credential resolver is asked about — the gateway operator is the resolved Galaxy account (#749).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_records_OpcUa_principal_in_comment()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
await driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice", ApiKeyId: "0a1b2c")],
|
|
CancellationToken.None);
|
|
|
|
ack.Calls.ShouldHaveSingleItem();
|
|
ack.Calls[0].Operator.ShouldBe("ack-op");
|
|
ack.Calls[0].Comment.ShouldBe("cmt [OPC UA: alice, key 0a1b2c]");
|
|
}
|
|
|
|
/// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
using var driver = NewDriver(feed, alarmAcknowledger: null);
|
|
|
|
await Should.ThrowAsync<NotSupportedException>(() =>
|
|
driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", null)],
|
|
CancellationToken.None));
|
|
}
|
|
|
|
private static GalaxyDriver NewDriver(
|
|
IGalaxyAlarmFeed alarmFeed, IGalaxyAlarmAcknowledger? alarmAcknowledger)
|
|
{
|
|
var options = new GalaxyDriverOptions(
|
|
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
|
|
new GalaxyMxAccessOptions("AlarmSourceTest"),
|
|
new GalaxyRepositoryOptions(),
|
|
new GalaxyReconnectOptions());
|
|
return new GalaxyDriver(
|
|
driverInstanceId: "drv-1",
|
|
options: options,
|
|
hierarchySource: null,
|
|
alarmAcknowledger: alarmAcknowledger,
|
|
alarmFeed: alarmFeed,
|
|
ackCredentialResolver: ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime.FixedAckCredentialResolver.Fallback());
|
|
}
|
|
|
|
private static GalaxyAlarmTransition NewTransition(
|
|
string alarmFullReference,
|
|
string sourceObjectReference,
|
|
GalaxyAlarmTransitionKind kind,
|
|
AlarmSeverity severity)
|
|
=> new(
|
|
AlarmFullReference: alarmFullReference,
|
|
SourceObjectReference: sourceObjectReference,
|
|
AlarmTypeName: "AnalogLimitAlarm.HiHi",
|
|
TransitionKind: kind,
|
|
SeverityBucket: severity,
|
|
OpcUaSeverity: 800,
|
|
RawMxAccessSeverity: 750,
|
|
OriginalRaiseTimestampUtc: null,
|
|
TransitionTimestampUtc: DateTime.UtcNow,
|
|
OperatorUser: string.Empty,
|
|
OperatorComment: string.Empty,
|
|
Category: "Process",
|
|
Description: "Tank 01 high-high level");
|
|
|
|
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
|
|
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
|
|
{
|
|
/// <summary>Gets a value indicating whether the feed has been started.</summary>
|
|
public bool Started { get; private set; }
|
|
|
|
/// <summary>Occurs when an alarm transition is emitted.</summary>
|
|
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
|
|
|
|
/// <summary>Marks the feed as started.</summary>
|
|
public void Start() => Started = true;
|
|
|
|
/// <summary>Emits an alarm transition to all subscribers.</summary>
|
|
/// <param name="transition">The transition to emit.</param>
|
|
public void Emit(GalaxyAlarmTransition transition)
|
|
=> OnAlarmTransition?.Invoke(this, transition);
|
|
|
|
/// <summary>Disposes the fake feed.</summary>
|
|
/// <returns>A completed task.</returns>
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Test double that records all acknowledge calls.</summary>
|
|
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
|
|
{
|
|
/// <summary>Gets the list of acknowledge calls recorded.</summary>
|
|
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
|
|
|
|
/// <summary>Records an acknowledge call.</summary>
|
|
/// <param name="alarmFullReference">The alarm full reference.</param>
|
|
/// <param name="comment">The acknowledgment comment.</param>
|
|
/// <param name="credential">The resolved Galaxy credential.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A completed task.</returns>
|
|
public Task<GalaxyAckOutcome> AcknowledgeAsync(string alarmFullReference, string comment, GalaxyAckCredential credential, CancellationToken cancellationToken)
|
|
{
|
|
Calls.Add((alarmFullReference, comment, credential.User));
|
|
return Task.FromResult(GalaxyAckOutcome.Applied);
|
|
}
|
|
}
|
|
|
|
/// <summary>Test double that represents a foreign alarm subscription handle.</summary>
|
|
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
|
|
{
|
|
/// <summary>Gets the diagnostic ID for this handle.</summary>
|
|
public string DiagnosticId => "foreign";
|
|
}
|
|
}
|