worker(alarms): exact-match ack resolution (no substring false-match) + ack-by-guid tests
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user