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)