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; /// /// Gitea #626 over the wire — an MTConnect CONDITION transition, streamed by a scripted Agent through the real /// , projected by the production 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. /// /// /// The one seam not exercised is DriverHostActor's routing (ConditionId → condition NodeId), which /// DriverHostActorNativeAlarmTests covers; here the driver's ConditionId is asserted to BE the /// materialised condition's RawPath, which is the whole contract that routing relies on. /// 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(); 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")), ]); /// An Agent whose /current reports the condition Normal and whose /sample a test drives. private sealed class ScriptedAgent : IMTConnectAgentClient { private readonly Channel _chunks = Channel.CreateUnbounded(); public void Push(MTConnectStreamsResult chunk) => _chunks.Writer.TryWrite(chunk); public Task ProbeAsync(CancellationToken ct) => Task.FromResult(new MTConnectProbeModel( [ new MTConnectDevice("dev1", "dev1", [], [new MTConnectDataItem(DataItemId, "system", "CONDITION", "SYSTEM", null, null, null, null)]), ])); public Task 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 SampleAsync( long from, [EnumeratorCancellation] CancellationToken ct) { while (true) { yield return await _chunks.Reader.ReadAsync(ct); } } public Task WriteObservationAsync( string deviceName, string dataItemId, string value, CancellationToken cancellationToken) => throw new NotSupportedException(); public void Dispose() => _chunks.Writer.TryComplete(); } }