diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
index f961e9c..aa74ffd 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
@@ -158,6 +158,131 @@ public sealed class SubtagAlarmConsumerTests
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
}
+ ///
+ /// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi),
+ /// AcknowledgeByName routes each ack to its own ack-comment subtag and never
+ /// conflates the shorter name with the longer one.
+ ///
+ [Fact]
+ public void AcknowledgeByName_PrefixNameDoesNotFalseMatch()
+ {
+ const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi";
+ const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi";
+ const string AckCommentHi = "Tank01.Level.Hi.AckComment";
+ const string AckCommentHiHi = "Tank01.Level.HiHi.AckComment";
+
+ AlarmSubtagTarget targetHi = new AlarmSubtagTarget
+ {
+ AlarmFullReference = ReferenceHi,
+ SourceObjectReference = "Tank01",
+ ActiveSubtag = "Tank01.Level.Hi.InAlarm",
+ AckedSubtag = "Tank01.Level.Hi.Acked",
+ AckCommentSubtag = AckCommentHi,
+ PrioritySubtag = "Tank01.Level.Hi.Priority",
+ };
+ AlarmSubtagTarget targetHiHi = new AlarmSubtagTarget
+ {
+ AlarmFullReference = ReferenceHiHi,
+ SourceObjectReference = "Tank01",
+ ActiveSubtag = "Tank01.Level.HiHi.InAlarm",
+ AckedSubtag = "Tank01.Level.HiHi.Acked",
+ AckCommentSubtag = AckCommentHiHi,
+ PrioritySubtag = "Tank01.Level.HiHi.Priority",
+ };
+
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(
+ source, new[] { targetHi, targetHiHi });
+ consumer.Subscribe(@"\\HOST\Galaxy!Area");
+
+ // Ack the shorter name — must write to the shorter target's subtag only.
+ int rcHi = consumer.AcknowledgeByName(
+ alarmName: "Tank01.Level.Hi",
+ providerName: "Galaxy",
+ groupName: "Area",
+ ackComment: "ack hi",
+ ackOperatorName: "op", ackOperatorNode: "WS01",
+ ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
+
+ Assert.Equal(0, rcHi);
+ Assert.NotNull(source.LastWrite);
+ Assert.Equal(AckCommentHi, source.LastWrite!.Value.Address);
+ Assert.Equal("ack hi", source.LastWrite.Value.Value);
+
+ // Ack the longer name — must write to the longer target's subtag.
+ int rcHiHi = consumer.AcknowledgeByName(
+ alarmName: "Tank01.Level.HiHi",
+ providerName: "Galaxy",
+ groupName: "Area",
+ ackComment: "ack hihi",
+ ackOperatorName: "op", ackOperatorNode: "WS01",
+ ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
+
+ Assert.Equal(0, rcHiHi);
+ Assert.NotNull(source.LastWrite);
+ Assert.Equal(AckCommentHiHi, source.LastWrite!.Value.Address);
+ Assert.Equal("ack hihi", source.LastWrite.Value.Value);
+ }
+
+ ///
+ /// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from
+ /// the alarm's full reference) to the correct target and writes the comment
+ /// to that target's ack-comment subtag.
+ ///
+ [Fact]
+ public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ // Raise a transition so the state machine sees the alarm, then capture
+ // the GUID stamped on the emitted event.
+ MxAlarmTransitionEvent? emitted = null;
+ consumer.AlarmTransitionEmitted += (_, e) => emitted = e;
+ source.Raise(ActiveSubtag, true, new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc));
+
+ Assert.NotNull(emitted);
+ Guid syntheticGuid = emitted!.Record.AlarmGuid;
+ Assert.NotEqual(Guid.Empty, syntheticGuid);
+
+ int rc = consumer.AcknowledgeByGuid(
+ alarmGuid: syntheticGuid,
+ ackComment: "guid ack",
+ ackOperatorName: "op",
+ ackOperatorNode: "WS01",
+ ackOperatorDomain: "CORP",
+ ackOperatorFullName: "Operator");
+
+ Assert.Equal(0, rc);
+ Assert.NotNull(source.LastWrite);
+ Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
+ Assert.Equal("guid ack", source.LastWrite.Value.Value);
+ }
+
+ ///
+ /// Verifies AcknowledgeByGuid returns non-zero and performs no write when
+ /// the supplied GUID is not known to the consumer.
+ ///
+ [Fact]
+ public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero()
+ {
+ FakeSource source = new FakeSource();
+ using SubtagAlarmConsumer consumer = BuildConsumer(source);
+ consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
+
+ int rc = consumer.AcknowledgeByGuid(
+ alarmGuid: Guid.NewGuid(),
+ ackComment: "should not write",
+ ackOperatorName: "op",
+ ackOperatorNode: "WS01",
+ ackOperatorDomain: "CORP",
+ ackOperatorFullName: "Operator");
+
+ Assert.NotEqual(0, rc);
+ Assert.Null(source.LastWrite);
+ }
+
private sealed class FakeSource : ISubtagAlarmSource
{
/// Raised when an advised subtag reports a new value.
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
index 5b22518..222b737 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs
@@ -147,9 +147,9 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
/// subtag; the operator-identity arguments are not surfaced through the
/// subtag write.
///
- /// The alarm name (object-rooted tag name or any reference fragment).
- /// The provider name (unused for matching).
- /// The group name (unused for matching).
+ /// The alarm name (object-rooted tag name as the dispatcher derives it).
+ /// The provider name used to recompose the full reference for lookup.
+ /// The group name used to recompose the full reference for lookup.
/// The acknowledgment comment.
/// The operator name (unused in subtag mode).
/// The operator node (unused in subtag mode).
@@ -171,7 +171,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
}
- AlarmSubtagTarget? target = ResolveTargetByName(alarmName);
+ AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
if (target is null)
{
return -1;
@@ -278,27 +278,21 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
record.AlarmGuid = SyntheticAlarmGuid.ForReference(reference);
}
- private AlarmSubtagTarget? ResolveTargetByName(string? alarmName)
+ private AlarmSubtagTarget? ResolveTargetByName(string? alarmName, string? providerName, string? groupName)
{
if (string.IsNullOrEmpty(alarmName))
{
return null;
}
- // Match a target whose full reference contains the supplied name. The
- // dispatcher derives (name, provider, group) from the composed
- // reference, so the object-rooted tag name is always a substring of the
- // target's AlarmFullReference.
- foreach (AlarmSubtagTarget target in targetsByReference.Values)
- {
- string reference = target.AlarmFullReference ?? string.Empty;
- if (reference.IndexOf(alarmName!, StringComparison.OrdinalIgnoreCase) >= 0)
- {
- return target;
- }
- }
-
- return null;
+ // Recompose the full reference from the same (provider, group, name)
+ // tuple the dispatcher derived, then do an exact OrdinalIgnoreCase
+ // lookup in targetsByReference. This avoids the false-positive that a
+ // substring scan would produce when one alarm name is a prefix of
+ // another (e.g. "Level.Hi" matching "Level.HiHi").
+ string composed = AlarmRecordTransitionMapper.ComposeFullReference(providerName, groupName, alarmName);
+ targetsByReference.TryGetValue(composed, out AlarmSubtagTarget? target);
+ return target;
}
private int WriteAckComment(AlarmSubtagTarget target, string ackComment)