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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user