Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/MTConnectConditionAlarmWireTests.cs
T
Joseph Doherty aba3aa7a8d fix(opcua): condition Severity is carried verbatim, not re-bucketed to 5 points
OtOpcUaNodeManager.MapSeverity re-bucketed every projected severity onto the
SDK's five EventSeverity values (<800 -> 700, else 900), defeating #626's exact
OpcUaSeverity: an MTConnect Fault (800) read and evented as 900; a scripted
alarm graded 1000 published 900 while /alerts said 1000; a native Low (200)
published 300. EventSeverity merely names points on the 1..1000 scale, so the
node now carries the value clamped to 1..1000 (ToPart9Severity), at
materialise, projection and in the delta gate.

MTConnect wire test now asserts Fault ActiveState=true/Severity=800 and the
Normal clear ActiveState=false/Retain=false/Severity=800.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:19:27 -04:00

166 lines
8.5 KiB
C#

using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #626 over the wire — an MTConnect CONDITION transition, streamed by a scripted Agent through the real
/// <see cref="MTConnectDriver"/>, projected by the production <see cref="NativeAlarmProjector"/> and written
/// through the production sink, reaches a client's event subscription on the RAW DEVICE FOLDER as a Part 9
/// condition event carrying the driver's message — then its return to normal does too.
/// </summary>
/// <remarks>
/// The one seam not exercised is <c>DriverHostActor</c>'s routing (ConditionId → condition NodeId), which
/// <c>DriverHostActorNativeAlarmTests</c> covers; here the driver's ConditionId is asserted to BE the
/// materialised condition's RawPath, which is the whole contract that routing relies on.
/// </remarks>
public sealed class MTConnectConditionAlarmWireTests
{
private const string DataItemId = "dev1_system_cond";
[Fact]
public async Task A_condition_fault_an_unavailable_blip_and_its_clear_reach_the_raw_device_folder()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("MTConnectConditionWire", encrypted: false, ct);
try
{
rig.MaterialiseNative();
using var session = await rig.ConnectAsync(anonymous: true, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var folderItem = RecordedEvents.AddEventItem(subscription, new NodeId(RawDeviceFolder, rawNs));
await subscription.ApplyChangesAsync(ct);
folderItem.Status.Error.ShouldBeNull();
var agent = new ScriptedAgent();
var driver = new MTConnectDriver(
new ZB.MOM.WW.OtOpcUa.Driver.MTConnect.MTConnectDriverOptions
{
AgentUri = "http://scripted-agent:5000",
RawTags = [new RawTagEntry(RawAlarmPath, $$"""{"address":"{{DataItemId}}"}""", false, "dev1")],
},
"mt-wire",
_ => agent);
// What DriverHostActor.ForwardNativeAlarm does for a routed transition, minus the routing: a
// quality-only event takes the Quality-only node path, anything else is a projected snapshot.
var projector = new NativeAlarmProjector();
var routed = new List<string>();
driver.OnAlarmEvent += (_, e) =>
{
lock (routed) routed.Add(e.ConditionId);
if (e is { IsQualityOnly: true, QualityStatusCode: { } quality })
{
rig.Sink.WriteAlarmQuality(e.ConditionId, quality, e.SourceTimestampUtc, AddressSpaceRealm.Raw);
return;
}
rig.Sink.WriteAlarmCondition(e.ConditionId, projector.Project(e.ConditionId, e), e.SourceTimestampUtc, AddressSpaceRealm.Raw);
};
await driver.InitializeAsync("{}", ct);
await driver.SubscribeAlarmsAsync([RawAlarmPath], ct);
agent.Push(Condition("Fault", "E17", "Spindle overtemperature", 108));
const string FaultMessage = "Spindle overtemperature [nativeCode=E17]";
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, FaultMessage));
var fault = events.ConditionEvents(folderItem, FaultMessage).ShouldHaveSingleItem();
fault[RecordedEvents.AckedField].Value.ShouldBe(false, "a fresh fault is unacknowledged");
fault[RecordedEvents.ActiveField].Value.ShouldBe(true, "a fault is active");
fault[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800, "Fault is the exact Part 9 severity 800, not the 900 bucket");
fault[RecordedEvents.RetainField].Value.ShouldBe(true);
// An operator acknowledges it (applied server-side, as the Part 9 method does), then a deploy clears
// the host's projector prior state.
rig.Sink.WriteAlarmCondition(RawAlarmPath,
new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true, Enabled: true,
AlarmShelvingKind.Unshelved, 800, FaultMessage),
DateTime.UtcNow, AddressSpaceRealm.Raw);
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(folderItem, FaultMessage).Count == 2);
projector = new NativeAlarmProjector();
// The adapter blips: UNAVAILABLE, then the same Fault. Each is a quality-only annotation that must
// leave the condition active, ACKNOWLEDGED, and carrying its message (review MAJOR 1 / MINOR 1).
agent.Push(Condition("UNAVAILABLE", null, null, 109));
agent.Push(Condition("Fault", "E17", "Spindle overtemperature", 110));
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(folderItem, FaultMessage).Count == 4);
foreach (var blip in events.ConditionEvents(folderItem, FaultMessage).Skip(2))
{
blip[RecordedEvents.AckedField].Value.ShouldBe(true, "an Unavailable blip must not reset the ack");
blip[RecordedEvents.ActiveField].Value.ShouldBe(true, "an Unavailable blip must not clear the fault");
}
agent.Push(Condition("Normal", null, null, 111));
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "SYSTEM Normal"));
var normal = events.ConditionEvents(folderItem, "SYSTEM Normal").ShouldHaveSingleItem();
normal[RecordedEvents.ActiveField].Value.ShouldBe(false, "Normal clears the condition");
normal[RecordedEvents.RetainField].Value.ShouldBe(false, "inactive + acknowledged is no longer retained");
normal[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800, "a clear keeps the severity of the alarm it clears");
lock (routed) routed.ShouldAllBe(id => id == RawAlarmPath, "ConditionId must be the alarm tag's RawPath");
await driver.ShutdownAsync(ct);
}
finally
{
await rig.DisposeAsync();
}
}
private static MTConnectStreamsResult Condition(string state, string? nativeCode, string? text, long sequence) =>
new(1, sequence + 1, sequence,
[
new MTConnectObservation(DataItemId, state, DateTime.UtcNow,
Condition: new MTConnectConditionDetail(text, nativeCode, null, null, "SYSTEM")),
]);
/// <summary>An Agent whose <c>/current</c> reports the condition Normal and whose <c>/sample</c> a test drives.</summary>
private sealed class ScriptedAgent : IMTConnectAgentClient
{
private readonly Channel<MTConnectStreamsResult> _chunks = Channel.CreateUnbounded<MTConnectStreamsResult>();
public void Push(MTConnectStreamsResult chunk) => _chunks.Writer.TryWrite(chunk);
public Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct) =>
Task.FromResult(new MTConnectProbeModel(
[
new MTConnectDevice("dev1", "dev1", [],
[new MTConnectDataItem(DataItemId, "system", "CONDITION", "SYSTEM", null, null, null, null)]),
]));
public Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct) =>
Task.FromResult(new MTConnectStreamsResult(1, 108, 1,
[
new MTConnectObservation(DataItemId, "Normal", DateTime.UtcNow,
Condition: new MTConnectConditionDetail(null, null, null, null, "SYSTEM")),
]));
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
long from, [EnumeratorCancellation] CancellationToken ct)
{
while (true)
{
yield return await _chunks.Reader.ReadAsync(ct);
}
}
public Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken) =>
throw new NotSupportedException();
public void Dispose() => _chunks.Writer.TryComplete();
}
}