Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
9.3 KiB
C#
245 lines
9.3 KiB
C#
using System.Threading.Channels;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using MxGateway.Contracts.Proto;
|
|
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>
|
|
/// PR B.2 — pins GalaxyDriver's IAlarmSource implementation. The driver bridges
|
|
/// EventPump.OnAlarmTransition (PR B.1) onto IAlarmSource.OnAlarmEvent and
|
|
/// forwards Acknowledge through IGalaxyAlarmAcknowledger (production:
|
|
/// GatewayGalaxyAlarmAcknowledger calling the gateway's AcknowledgeAlarm RPC
|
|
/// from PR E.2).
|
|
/// </summary>
|
|
public sealed class GalaxyDriverAlarmSourceTests
|
|
{
|
|
[Fact]
|
|
public async Task SubscribeAlarmsAsync_returns_handle_and_event_fires_after_pump_alarm()
|
|
{
|
|
var subscriber = new ManualSubscriber();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(subscriber, ack);
|
|
|
|
// Subscribe so OnAlarmEvent has a registered handle to fire under.
|
|
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
handle.ShouldNotBeNull();
|
|
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
// SubscribeAsync to start the EventPump (alarm wiring is lazy on first sub).
|
|
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
|
|
|
await subscriber.EmitAlarmAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnAlarmTransition,
|
|
OnAlarmTransition = new OnAlarmTransitionEvent
|
|
{
|
|
AlarmFullReference = "Tank01.Level.HiHi",
|
|
SourceObjectReference = "Tank01",
|
|
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
|
TransitionKind = AlarmTransitionKind.Raise,
|
|
Severity = 750,
|
|
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
Description = "Tank 01 high-high level",
|
|
},
|
|
});
|
|
|
|
// Drain pump events.
|
|
for (var i = 0; i < 20 && observed.Count == 0; i++)
|
|
{
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
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);
|
|
observed[0].SubscriptionHandle.ShouldBe(handle);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OnAlarmEvent_does_not_fire_when_no_subscription_active()
|
|
{
|
|
var subscriber = new ManualSubscriber();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(subscriber, ack);
|
|
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
// Start the pump via a data subscription so alarm events flow but no alarm
|
|
// subscription is registered → OnAlarmEvent is suppressed.
|
|
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
|
await subscriber.EmitAlarmAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnAlarmTransition,
|
|
OnAlarmTransition = new OnAlarmTransitionEvent
|
|
{
|
|
AlarmFullReference = "Tank01.Level.HiHi",
|
|
TransitionKind = AlarmTransitionKind.Raise,
|
|
Severity = 600,
|
|
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
},
|
|
});
|
|
await Task.Delay(150);
|
|
|
|
observed.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
|
|
{
|
|
var subscriber = new ManualSubscriber();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(subscriber, ack);
|
|
|
|
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
|
|
|
await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
|
|
|
await subscriber.EmitAlarmAsync(new MxEvent
|
|
{
|
|
Family = MxEventFamily.OnAlarmTransition,
|
|
OnAlarmTransition = new OnAlarmTransitionEvent
|
|
{
|
|
AlarmFullReference = "Tank01.Level.HiHi",
|
|
TransitionKind = AlarmTransitionKind.Raise,
|
|
Severity = 600,
|
|
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
},
|
|
});
|
|
await Task.Delay(150);
|
|
|
|
observed.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
|
|
{
|
|
var subscriber = new ManualSubscriber();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(subscriber, ack);
|
|
|
|
var foreignHandle = new ForeignAlarmHandle();
|
|
await Should.ThrowAsync<ArgumentException>(() =>
|
|
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
|
|
{
|
|
var subscriber = new ManualSubscriber();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(subscriber, 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");
|
|
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
|
|
{
|
|
var subscriber = new ManualSubscriber();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(subscriber, ack);
|
|
|
|
await driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("Tank01.Level.HiHi", string.Empty, null)],
|
|
CancellationToken.None);
|
|
|
|
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
|
|
{
|
|
var subscriber = new ManualSubscriber();
|
|
using var driver = NewDriver(subscriber, alarmAcknowledger: null);
|
|
|
|
await Should.ThrowAsync<NotSupportedException>(() =>
|
|
driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", null)],
|
|
CancellationToken.None));
|
|
}
|
|
|
|
private static GalaxyDriver NewDriver(
|
|
ManualSubscriber subscriber, 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,
|
|
dataReader: null,
|
|
dataWriter: null,
|
|
subscriber: subscriber,
|
|
alarmAcknowledger: alarmAcknowledger);
|
|
}
|
|
|
|
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
|
|
{
|
|
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
|
|
|
|
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
|
|
{
|
|
Calls.Add((alarmFullReference, comment, operatorUser));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
|
|
{
|
|
public string DiagnosticId => "foreign";
|
|
}
|
|
|
|
private sealed class ManualSubscriber : IGalaxySubscriber
|
|
{
|
|
private readonly Channel<MxEvent> _stream =
|
|
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
|
|
|
|
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
|
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
|
{
|
|
var results = new List<SubscribeResult>();
|
|
var nextHandle = 100;
|
|
foreach (var r in fullReferences)
|
|
{
|
|
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true });
|
|
}
|
|
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
|
|
}
|
|
|
|
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
|
=> Task.CompletedTask;
|
|
|
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
|
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
|
|
|
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
|
|
}
|
|
}
|