worker(alarms): exact-match ack resolution (no substring false-match) + ack-by-guid tests

This commit is contained in:
Joseph Doherty
2026-06-13 09:42:00 -04:00
parent 4bd757a136
commit fd64b9260c
2 changed files with 138 additions and 19 deletions
@@ -158,6 +158,131 @@ public sealed class SubtagAlarmConsumerTests
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Verifies AcknowledgeByGuid returns non-zero and performs no write when
/// the supplied GUID is not known to the consumer.
/// </summary>
[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
{
/// <summary>Raised when an advised subtag reports a new value.</summary>
@@ -147,9 +147,9 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
/// subtag; the operator-identity arguments are not surfaced through the
/// subtag write.
/// </summary>
/// <param name="alarmName">The alarm name (object-rooted tag name or any reference fragment).</param>
/// <param name="providerName">The provider name (unused for matching).</param>
/// <param name="groupName">The group name (unused for matching).</param>
/// <param name="alarmName">The alarm name (object-rooted tag name as the dispatcher derives it).</param>
/// <param name="providerName">The provider name used to recompose the full reference for lookup.</param>
/// <param name="groupName">The group name used to recompose the full reference for lookup.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name (unused in subtag mode).</param>
/// <param name="ackOperatorNode">The operator node (unused in subtag mode).</param>
@@ -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)