From ee459f43e1551bbdcedb94c117ce7a8120530895 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 13 Jun 2026 10:26:28 -0400 Subject: [PATCH] test(alarms): opt-in live subtag-fallback smoke test (Skip by default) Adds AlarmSubtagLiveSmokeTests to validate the open design item from Task 17: confirms that LmxSubtagAlarmSource (real MxAccessComObjectFactory) wired to SubtagAlarmConsumer synthesizes degraded Raise transitions with stable synthetic GUIDs from Galaxy alarm subtags, and that AcknowledgeByName writes the ack-comment subtag (rc=0). PLACEHOLDER_* subtag addresses are best-guess and must be verified against MXAccess-Public-API.md + live Galaxy before flipping Skip. --- .../Probes/AlarmSubtagLiveSmokeTests.cs | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs new file mode 100644 index 0000000..d6c3cfa --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs @@ -0,0 +1,435 @@ +// AlarmSubtagLiveSmokeTests.cs +// +// Validates the open design item from Task 17: exact subtag names and the +// canonical AlarmSubtagTarget reference shape for a known Galaxy alarm. +// +// This test exercises the full subtag-fallback pipeline end-to-end against +// a real Galaxy + MXAccess install: +// LmxSubtagAlarmSource (own LMXProxyServerClass) -> +// SubtagAlarmConsumer (state machine + AcknowledgeByName write) -> +// synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid) +// +// HOW TO RUN: +// 1. On the dev rig with AVEVA System Platform installed and Galaxy running: +// $env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = "1" +// 2. Remove (or set to null) the Skip parameter on the [Fact] below. +// 3. Run with an alarm flip script (same one used by AlarmsLiveSmokeTests) +// so that TestMachine_001.TestAlarm001 toggles its Active/Acked subtags +// on a ~10 s cadence. +// 4. VERIFY the placeholder subtag names (PLACEHOLDER_* constants below) +// against the live Galaxy before flipping Skip -- see VERIFY comment block. +// +// OPEN ITEM (Task 17): the exact subtag address strings are not yet confirmed +// from a live Galaxy or from C:\Users\dohertj2\Desktop\mxaccess +// docs/MXAccess-Public-API.md. Flip this test only after verifying them. +// +// net48/x86 constraints: +// - No init-only properties, records, index/range operators, C# 8+ pattern +// matches beyond what the existing Worker.Tests files use. +// - All Booleans from MXAccess arrive as boxed int (0 / non-zero); coerce +// with Convert.ToBoolean or cast to int first. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; +using Xunit.Abstractions; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; + +/// +/// Live dev-rig smoke test for the subtag-fallback alarm pipeline. +/// Validates the open design item from Task 17: confirms that +/// wired to a real +/// LMXProxyServerClass + can +/// synthesize records from Galaxy +/// alarm subtags, and that +/// writes the ack-comment subtag successfully. +/// +/// Skip-gated; flip Skip=null on the dev rig with the alarm flip +/// script running. VERIFY the PLACEHOLDER_* subtag names before running. +/// +public sealed class AlarmSubtagLiveSmokeTests +{ + // ------------------------------------------------------------------------- + // PLACEHOLDER references — VERIFY before flipping Skip. + // + // These values are the best-guess subtag addresses for TestMachine_001.TestAlarm001 + // as used by the existing AlarmsLiveSmokeTests (which watches the same Galaxy + // provider). The exact attribute suffix strings (.InAlarm, .Acked, + // .AckComment, .Priority) MUST be confirmed against: + // - C:\Users\dohertj2\Desktop\mxaccess\docs\MXAccess-Public-API.md + // - A live Galaxy attribute browse or MXTraceHarness capture + // before this test is flipped on. The subtag address format is + // . + // where the alarm attribute names are Galaxy-attribute names surfaced + // through MXAccess. Known AVEVA attribute names are .InAlarm (bool), + // .Acked (bool), .Priority (int), and a writable comment attribute whose + // exact name varies by alarm template (commonly .AlarmComment or + // .AckComment). + // ------------------------------------------------------------------------- + + /// The Galaxy provider expression used by the existing live smoke tests. + private static readonly string Provider = + string.Format(@"\\{0}\Galaxy", Environment.MachineName); + + /// The Galaxy group (provider sub-path) containing the test alarm. + private const string Group = "DEV"; + + /// The test alarm's tag name as the dispatcher composes it (Group.TagName). + private const string AlarmTagName = "TestMachine_001.TestAlarm001"; + + // PLACEHOLDER — VERIFY against MXAccess-Public-API.md and live Galaxy. + // Typical InProcess Platform alarm active subtag: ".InAlarm" + private const string PlaceholderActiveSubtag = "TestMachine_001.TestAlarm001.InAlarm"; + + // PLACEHOLDER — VERIFY. Typical: ".Acked" + private const string PlaceholderAckedSubtag = "TestMachine_001.TestAlarm001.Acked"; + + // PLACEHOLDER — VERIFY. The writable ack-comment attribute varies by alarm + // template; common names are .AlarmComment, .AckComment, or .OperComment. + // Use a live Galaxy attribute browse (InTouch AlarmDB or MXTraceHarness + // GetItemList) to confirm before flipping. + private const string PlaceholderAckCommentSubtag = "TestMachine_001.TestAlarm001.AlarmComment"; + + // PLACEHOLDER — VERIFY. Typically: ".Priority" + private const string PlaceholderPrioritySubtag = "TestMachine_001.TestAlarm001.Priority"; + + // ------------------------------------------------------------------------- + + private static readonly TimeSpan RaiseWaitTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ClearWaitTimeout = TimeSpan.FromSeconds(30); + private static readonly string AlarmFullReference = + AlarmRecordTransitionMapper.ComposeFullReference(Provider, Group, AlarmTagName); + + private readonly ITestOutputHelper output; + private readonly Stopwatch elapsed = Stopwatch.StartNew(); + private readonly ConcurrentQueue log = + new ConcurrentQueue(); + + /// Initializes a new instance of the AlarmSubtagLiveSmokeTests class. + /// Test output helper for logging. + public AlarmSubtagLiveSmokeTests(ITestOutputHelper output) + { + this.output = output; + } + + /// + /// Verifies the subtag-fallback pipeline: advises alarm subtags through a + /// real LMXProxyServerClass, collects a Raise then a Clear + /// transition synthesized by , confirms + /// the Degraded flag and synthetic GUID are stamped, then + /// AcknowledgeByName and verifies the ack-comment write returns 0. + /// + [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm flip script running. Subtag fallback path. VERIFY PLACEHOLDER_* subtag names before enabling.")] + public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges() + { + Exception? threadException = null; + ManualResetEventSlim done = new ManualResetEventSlim(false); + Thread thread = new Thread(() => + { + try { RunSmoke(); } + catch (Exception ex) { threadException = ex; } + finally { done.Set(); } + }); + thread.IsBackground = false; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + done.Wait(); + thread.Join(); + + output.WriteLine(string.Format("Captured {0} log line(s):", log.Count)); + string? line; + while (log.TryDequeue(out line)) + { + output.WriteLine(line); + } + + if (threadException != null) + { + throw threadException; + } + } + + private void RunSmoke() + { + Log(string.Format("AlarmFullReference: {0}", AlarmFullReference)); + Log("VERIFY: PlaceholderActiveSubtag = " + PlaceholderActiveSubtag); + Log("VERIFY: PlaceholderAckedSubtag = " + PlaceholderAckedSubtag); + Log("VERIFY: PlaceholderAckCommentSubtag = " + PlaceholderAckCommentSubtag); + Log("VERIFY: PlaceholderPrioritySubtag = " + PlaceholderPrioritySubtag); + Log(string.Format("RaiseWaitTimeout={0}s ClearWaitTimeout={1}s", + RaiseWaitTimeout.TotalSeconds, ClearWaitTimeout.TotalSeconds)); + + // Build target with PLACEHOLDER subtag names. + // VERIFY these against MXAccess-Public-API.md and a live Galaxy + // attribute browse before flipping Skip=null. + AlarmSubtagTarget target = new AlarmSubtagTarget + { + AlarmFullReference = AlarmFullReference, + SourceObjectReference = AlarmTagName, + ActiveSubtag = PlaceholderActiveSubtag, + AckedSubtag = PlaceholderAckedSubtag, + AckCommentSubtag = PlaceholderAckCommentSubtag, + PrioritySubtag = PlaceholderPrioritySubtag, + }; + + List watchList = new List { target }; + + // Construct the real COM-backed subtag source using the production + // factory (MxAccessComObjectFactory -> new LMXProxyServerClass()). + // This is the same factory the worker uses in production; no test + // double is involved on this path. + MxAccessComObjectFactory factory = new MxAccessComObjectFactory(); + LmxSubtagAlarmSource source = new LmxSubtagAlarmSource(factory, clientName: null); + + // SubtagAlarmConsumer wraps the source and drives the state machine. + using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, watchList); + + // Collect emitted transitions on the STA (handler fires on the same + // STA that services the COM OnDataChange callback). + ConcurrentQueue transitions = + new ConcurrentQueue(); + consumer.AlarmTransitionEmitted += (_, e) => + { + Log(string.Format("Transition emitted: {0}", DescribeTransition(e))); + transitions.Enqueue(e); + }; + + // Subscribe binds Advise on all observable subtags (Active, Acked, + // Priority). The subscription expression is unused in subtag mode; pass + // something recognizable for diagnostics. + string subscriptionExpression = string.Format(@"\\{0}\Galaxy!{1}", Environment.MachineName, Group); + Log(string.Format("Calling Subscribe({0}) ...", subscriptionExpression)); + consumer.Subscribe(subscriptionExpression); + Log("Subscribe returned OK."); + + // 1. Wait for a Raise transition. The alarm flip script (same one + // used by AlarmsLiveSmokeTests) writes the active subtag on a + // ~10 s cadence. LmxSubtagAlarmSource delivers OnDataChange via + // the Windows message pump on the STA, so we must pump messages + // here while we wait — mirroring how AlarmsLiveSmokeTests drives + // its WnWrapAlarmConsumer.PollOnce() from the STA in a tight loop. + Log(string.Format("Waiting up to {0}s for a Raise transition ...", RaiseWaitTimeout.TotalSeconds)); + MxAlarmTransitionEvent? raiseEvent = WaitForTransitionKind( + transitions, AlarmTransitionKind.Raise, RaiseWaitTimeout, "Raise"); + + Assert.NotNull(raiseEvent); + Assert.True(raiseEvent!.Record.Degraded, + "Subtag-synthesized records must have Degraded=true."); + Assert.NotEqual(Guid.Empty, raiseEvent.Record.AlarmGuid, + "SubtagAlarmConsumer must stamp a synthetic GUID on the transition."); + Assert.Equal(MxAlarmStateKind.UnackAlm, raiseEvent.Record.State, + "A Raise transition must leave the record in UnackAlm state."); + Log(string.Format("Raise confirmed: AlarmGuid={0} Degraded={1} State={2}", + raiseEvent.Record.AlarmGuid, raiseEvent.Record.Degraded, raiseEvent.Record.State)); + + // 2. Snapshot active alarms and confirm the raised alarm is present. + IReadOnlyList snapshot = consumer.SnapshotActiveAlarms(); + Log(string.Format("SnapshotActiveAlarms count={0}", snapshot.Count)); + foreach (MxAlarmSnapshotRecord s in snapshot) + { + Log(string.Format(" snapshot: TagName='{0}' Group='{1}' State={2} Degraded={3} Guid={4}", + s.TagName, s.Group, s.State, s.Degraded, s.AlarmGuid)); + } + + bool foundInSnapshot = false; + foreach (MxAlarmSnapshotRecord s in snapshot) + { + if (string.Equals( + AlarmRecordTransitionMapper.ComposeFullReference(s.ProviderName, s.Group, s.TagName), + AlarmFullReference, + StringComparison.OrdinalIgnoreCase)) + { + foundInSnapshot = true; + break; + } + } + + Assert.True(foundInSnapshot, + string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference)); + + // 3. AcknowledgeByName — writes the ack-comment subtag. + // The dispatcher derives (alarmName, provider, group) via the same + // TryParseReference logic as AlarmsLiveSmokeTests. + Log("Calling AcknowledgeByName ..."); + int rc = consumer.AcknowledgeByName( + alarmName: AlarmTagName, + providerName: Provider, + groupName: Group, + ackComment: "subtag-fallback-smoke ack", + ackOperatorName: Environment.UserName, + ackOperatorNode: Environment.MachineName, + ackOperatorDomain: Environment.UserDomainName ?? string.Empty, + ackOperatorFullName: Environment.UserName); + + Log(string.Format("AcknowledgeByName -> rc={0}", rc)); + Assert.Equal(0, rc); + + // 4. Wait for a Clear or Acknowledge transition to confirm the state + // machine continues tracking after the ack write. + Log(string.Format("Waiting up to {0}s for a Clear or Acknowledge transition ...", + ClearWaitTimeout.TotalSeconds)); + MxAlarmTransitionEvent? postAckEvent = WaitForAnyTransition(transitions, ClearWaitTimeout, "post-ack"); + + // A null here is not a hard failure: the test alarm may not have + // cleared within the window, and the ack-comment write already + // confirmed the subtag path is wired. Log and assert non-null + // only when the post-ack transition is expected. + if (postAckEvent != null) + { + AlarmTransitionKind postAckKind = AlarmRecordTransitionMapper.MapTransition( + postAckEvent.PreviousState, postAckEvent.Record.State); + Log(string.Format("Post-ack transition: {0}", DescribeTransition(postAckEvent))); + Assert.NotEqual(AlarmTransitionKind.Unspecified, postAckKind); + } + else + { + Log("No post-ack transition within timeout — ack-comment write succeeded; state machine still live."); + } + + Log("Smoke test complete."); + } + + private MxAlarmTransitionEvent? WaitForTransitionKind( + ConcurrentQueue queue, + AlarmTransitionKind kind, + TimeSpan timeout, + string label) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + // Pump the STA Windows message queue so COM OnDataChange callbacks + // can be delivered. LMXProxyServerClass is apartment-threaded and + // requires the STA to be pumping; a bare Thread.Sleep would stall + // the pump. + PumpMessages(); + + MxAlarmTransitionEvent? evt; + if (queue.TryDequeue(out evt) && evt != null) + { + AlarmTransitionKind evtKind = AlarmRecordTransitionMapper.MapTransition( + evt.PreviousState, evt.Record.State); + if (evtKind == kind) + { + return evt; + } + + Log(string.Format("Skipped transition kind={0} while waiting for {1}.", evtKind, label)); + // Can't re-enqueue; log and continue. + } + + Thread.Sleep(250); + } + + Log(string.Format("Timed out waiting for {0} transition after {1}s.", label, timeout.TotalSeconds)); + return null; + } + + private MxAlarmTransitionEvent? WaitForAnyTransition( + ConcurrentQueue queue, + TimeSpan timeout, + string label) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + PumpMessages(); + + MxAlarmTransitionEvent? evt; + if (queue.TryDequeue(out evt) && evt != null) + { + return evt; + } + + Thread.Sleep(250); + } + + Log(string.Format("Timed out waiting for any transition ({0}) after {1}s.", label, timeout.TotalSeconds)); + return null; + } + + /// + /// Runs a single pass of the Windows STA message pump using a + /// non-blocking PeekMessage/DispatchMessage loop so COM + /// OnDataChange callbacks from LMXProxyServerClass + /// (ThreadingModel=Apartment) can be delivered on this thread. + /// Mirrors the pump pattern documented in + /// docs/MxAccessWorkerInstanceDesign.md. + /// + private static void PumpMessages() + { + NativeMethods.MSG msg; + // Drain all currently posted messages; return as soon as the queue + // is empty. + while (NativeMethods.PeekMessage(out msg, IntPtr.Zero, 0, 0, NativeMethods.PM_REMOVE)) + { + NativeMethods.TranslateMessage(ref msg); + NativeMethods.DispatchMessage(ref msg); + } + } + + private static string DescribeTransition(MxAlarmTransitionEvent e) + { + AlarmTransitionKind kind = AlarmRecordTransitionMapper.MapTransition( + e.PreviousState, e.Record.State); + return string.Format( + "Kind={0} PreviousState={1} State={2} TagName='{3}' Group='{4}' Provider='{5}' Degraded={6} Guid={7}", + kind, + e.PreviousState, + e.Record.State, + e.Record.TagName, + e.Record.Group, + e.Record.ProviderName, + e.Record.Degraded, + e.Record.AlarmGuid); + } + + private void Log(string line) + { + log.Enqueue(string.Format("[t={0:F3}s] {1}", elapsed.Elapsed.TotalSeconds, line)); + } + + // ------------------------------------------------------------------------- + // Minimal P/Invoke shim so the STA pump can be driven without pulling in + // the full StaRuntime machinery from the Worker project. The signatures + // mirror those in MxAccessStaRuntime and are well-known Win32. + // ------------------------------------------------------------------------- + private static class NativeMethods + { + internal const uint PM_REMOVE = 0x0001; + + [StructLayout(LayoutKind.Sequential)] + internal struct MSG + { + internal IntPtr hwnd; + internal uint message; + internal IntPtr wParam; + internal IntPtr lParam; + internal uint time; + internal int ptX; + internal int ptY; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool PeekMessage( + out MSG lpMsg, + IntPtr hWnd, + uint wMsgFilterMin, + uint wMsgFilterMax, + uint wRemoveMsg); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool TranslateMessage(ref MSG lpMsg); + + [DllImport("user32.dll")] + internal static extern IntPtr DispatchMessage(ref MSG lpmsg); + } +}