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.
This commit is contained in:
Joseph Doherty
2026-06-13 10:26:28 -04:00
parent ebf1d95f72
commit ee459f43e1
@@ -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;
/// <summary>
/// Live dev-rig smoke test for the subtag-fallback alarm pipeline.
/// Validates the open design item from Task 17: confirms that
/// <see cref="LmxSubtagAlarmSource"/> wired to a real
/// <c>LMXProxyServerClass</c> + <see cref="SubtagAlarmConsumer"/> can
/// synthesize <see cref="MxAlarmTransitionEvent"/> records from Galaxy
/// alarm subtags, and that <see cref="SubtagAlarmConsumer.AcknowledgeByName"/>
/// writes the ack-comment subtag successfully.
///
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with the alarm flip
/// script running. VERIFY the PLACEHOLDER_* subtag names before running.
/// </summary>
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
// <ObjectTagName>.<AlarmAttributeName>
// 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).
// -------------------------------------------------------------------------
/// <summary>The Galaxy provider expression used by the existing live smoke tests.</summary>
private static readonly string Provider =
string.Format(@"\\{0}\Galaxy", Environment.MachineName);
/// <summary>The Galaxy group (provider sub-path) containing the test alarm.</summary>
private const string Group = "DEV";
/// <summary>The test alarm's tag name as the dispatcher composes it (Group.TagName).</summary>
private const string AlarmTagName = "TestMachine_001.TestAlarm001";
// PLACEHOLDER — VERIFY against MXAccess-Public-API.md and live Galaxy.
// Typical InProcess Platform alarm active subtag: "<TagName>.InAlarm"
private const string PlaceholderActiveSubtag = "TestMachine_001.TestAlarm001.InAlarm";
// PLACEHOLDER — VERIFY. Typical: "<TagName>.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: "<TagName>.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<string> log =
new ConcurrentQueue<string>();
/// <summary>Initializes a new instance of the AlarmSubtagLiveSmokeTests class.</summary>
/// <param name="output">Test output helper for logging.</param>
public AlarmSubtagLiveSmokeTests(ITestOutputHelper output)
{
this.output = output;
}
/// <summary>
/// Verifies the subtag-fallback pipeline: advises alarm subtags through a
/// real <c>LMXProxyServerClass</c>, collects a Raise then a Clear
/// transition synthesized by <see cref="SubtagAlarmConsumer"/>, confirms
/// the Degraded flag and synthetic GUID are stamped, then
/// AcknowledgeByName and verifies the ack-comment write returns 0.
/// </summary>
[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<AlarmSubtagTarget> watchList = new List<AlarmSubtagTarget> { 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<MxAlarmTransitionEvent> transitions =
new ConcurrentQueue<MxAlarmTransitionEvent>();
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<MxAlarmSnapshotRecord> 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<MxAlarmTransitionEvent> 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<MxAlarmTransitionEvent> 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;
}
/// <summary>
/// Runs a single pass of the Windows STA message pump using a
/// non-blocking PeekMessage/DispatchMessage loop so COM
/// <c>OnDataChange</c> callbacks from <c>LMXProxyServerClass</c>
/// (ThreadingModel=Apartment) can be delivered on this thread.
/// Mirrors the pump pattern documented in
/// <c>docs/MxAccessWorkerInstanceDesign.md</c>.
/// </summary>
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);
}
}