95b389497a
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
158 lines
7.7 KiB
C#
158 lines
7.7 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");
|
|
|
|
// 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");
|
|
}
|
|
|
|
agent.Push(Condition("Normal", null, null, 111));
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "SYSTEM Normal"));
|
|
|
|
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();
|
|
}
|
|
}
|