diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
index 41ff65a..a8b1ad5 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
@@ -288,6 +288,150 @@ public sealed class AlarmSubtagLiveSmokeTests
Log("CONFIRMED: runtime subtag path resolves and the AckMsg write succeeded.");
}
+ ///
+ /// Self-driving full lifecycle test. Drives the alarm by writing its
+ /// source attribute (TestMachine_001.TestAlarm001) false then
+ /// true then false, and asserts that
+ /// synthesizes a Raise (after true) and a Clear (after the final false),
+ /// with an acknowledge (AckMsg write) in between. No external alarm-flip
+ /// script required. Runs only when
+ /// MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1; has a side effect
+ /// (toggles the live test alarm).
+ ///
+ [LiveMxAccessFact]
+ public void LiveSelfDriven_RaiseAckClear_SynthesizesTransitions()
+ {
+ Exception? threadException = null;
+ ManualResetEventSlim done = new ManualResetEventSlim(false);
+ Thread thread = new Thread(() =>
+ {
+ try { RunSelfDriven(); }
+ catch (Exception ex) { threadException = ex; }
+ finally { done.Set(); }
+ });
+ thread.IsBackground = false;
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ done.Wait();
+ thread.Join();
+
+ List lines = new List();
+ output.WriteLine(string.Format("Captured {0} log line(s):", log.Count));
+ string? line;
+ while (log.TryDequeue(out line))
+ {
+ output.WriteLine(line);
+ lines.Add(line);
+ }
+
+ string logPath = Environment.GetEnvironmentVariable("PROBE_LOG")
+ ?? System.IO.Path.Combine(System.IO.Path.GetTempPath(), "alarm-subtag-selfdriven.log");
+ try { System.IO.File.WriteAllLines(logPath, lines); }
+ catch (Exception writeEx) { output.WriteLine("self-driven-log write failed: " + writeEx.Message); }
+
+ if (threadException != null)
+ {
+ throw threadException;
+ }
+ }
+
+ private void RunSelfDriven()
+ {
+ // The discrete alarm's source attribute. Writing its boolean value
+ // drives the alarm: true -> in alarm, false -> normal.
+ const string AlarmSourceTag = "TestMachine_001.TestAlarm001";
+
+ Log("=== Self-driven Raise/Ack/Clear lifecycle ===");
+ Log("AlarmFullReference: " + AlarmFullReference);
+ Log("Alarm source tag (driven): " + AlarmSourceTag);
+
+ AlarmSubtagTarget target = new AlarmSubtagTarget
+ {
+ AlarmFullReference = AlarmFullReference,
+ SourceObjectReference = AlarmTagName,
+ ActiveSubtag = PlaceholderActiveSubtag,
+ AckedSubtag = PlaceholderAckedSubtag,
+ AckCommentSubtag = PlaceholderAckCommentSubtag,
+ PrioritySubtag = PlaceholderPrioritySubtag,
+ };
+ List watchList = new List { target };
+
+ MxAccessComObjectFactory factory = new MxAccessComObjectFactory();
+ LmxSubtagAlarmSource source = new LmxSubtagAlarmSource(factory, clientName: null);
+ using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, watchList);
+
+ ConcurrentQueue transitions =
+ new ConcurrentQueue();
+ consumer.AlarmTransitionEmitted += (_, e) =>
+ {
+ Log("Transition emitted: " + DescribeTransition(e));
+ transitions.Enqueue(e);
+ };
+
+ // Advise the alarm subtags (InAlarm/Acked/Priority/AckMsg).
+ consumer.Subscribe(string.Format(@"\\{0}\Galaxy!{1}", Environment.MachineName, Group));
+ // Also advise the source attribute so it is an active, writable item
+ // (MXAccess rejects writes to added-but-not-advised items).
+ source.Advise(new List { AlarmSourceTag });
+ Log("Subscribed + advised source. Pumping to activate items ...");
+ PumpFor(TimeSpan.FromSeconds(7));
+
+ // Baseline OFF: drive the source false and drain whatever transitions
+ // result (including any initial Raise if the alarm started active).
+ Log("Driving source = false (baseline OFF) ...");
+ source.Write(AlarmSourceTag, false);
+ PumpFor(TimeSpan.FromSeconds(5));
+ int drained = 0;
+ MxAlarmTransitionEvent? ignored;
+ while (transitions.TryDequeue(out ignored)) { drained++; }
+ Log(string.Format("Drained {0} baseline transition(s).", drained));
+
+ // RAISE: drive the source true.
+ Log("Driving source = true (RAISE) ...");
+ source.Write(AlarmSourceTag, true);
+ 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);
+ Assert.Equal(MxAlarmStateKind.UnackAlm, raiseEvent.Record.State);
+ Log(string.Format("RAISE confirmed: Guid={0} State={1}", raiseEvent.Record.AlarmGuid, raiseEvent.Record.State));
+
+ // ACK: write the ack-comment subtag.
+ Log("AcknowledgeByName (writes AckMsg) ...");
+ int rc = consumer.AcknowledgeByName(
+ alarmName: AlarmTagName,
+ providerName: Provider,
+ groupName: Group,
+ ackComment: "self-driven-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);
+
+ // CLEAR: drive the source false.
+ Log("Driving source = false (CLEAR) ...");
+ source.Write(AlarmSourceTag, false);
+ MxAlarmTransitionEvent? clearEvent = WaitForTransitionKind(
+ transitions, AlarmTransitionKind.Clear, ClearWaitTimeout, "Clear");
+ Assert.NotNull(clearEvent);
+ Log(string.Format("CLEAR confirmed: State={0}", clearEvent!.Record.State));
+
+ Log("Self-driven lifecycle complete: Raise -> Ack -> Clear all synthesized.");
+ }
+
+ private void PumpFor(TimeSpan duration)
+ {
+ DateTime deadline = DateTime.UtcNow + duration;
+ while (DateTime.UtcNow < deadline)
+ {
+ PumpMessages();
+ Thread.Sleep(100);
+ }
+ }
+
private void RunSmoke()
{
Log(string.Format("AlarmFullReference: {0}", AlarmFullReference));