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);
|
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
|
private sealed class FakeSource : ISubtagAlarmSource
|
||||||
{
|
{
|
||||||
/// <summary>Raised when an advised subtag reports a new value.</summary>
|
/// <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; the operator-identity arguments are not surfaced through the
|
||||||
/// subtag write.
|
/// subtag write.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="alarmName">The alarm name (object-rooted tag name or any reference fragment).</param>
|
/// <param name="alarmName">The alarm name (object-rooted tag name as the dispatcher derives it).</param>
|
||||||
/// <param name="providerName">The provider name (unused for matching).</param>
|
/// <param name="providerName">The provider name used to recompose the full reference for lookup.</param>
|
||||||
/// <param name="groupName">The group name (unused for matching).</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="ackComment">The acknowledgment comment.</param>
|
||||||
/// <param name="ackOperatorName">The operator name (unused in subtag mode).</param>
|
/// <param name="ackOperatorName">The operator name (unused in subtag mode).</param>
|
||||||
/// <param name="ackOperatorNode">The operator node (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));
|
throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
|
||||||
}
|
}
|
||||||
|
|
||||||
AlarmSubtagTarget? target = ResolveTargetByName(alarmName);
|
AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
|
||||||
if (target is null)
|
if (target is null)
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
@@ -278,27 +278,21 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
record.AlarmGuid = SyntheticAlarmGuid.ForReference(reference);
|
record.AlarmGuid = SyntheticAlarmGuid.ForReference(reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AlarmSubtagTarget? ResolveTargetByName(string? alarmName)
|
private AlarmSubtagTarget? ResolveTargetByName(string? alarmName, string? providerName, string? groupName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(alarmName))
|
if (string.IsNullOrEmpty(alarmName))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match a target whose full reference contains the supplied name. The
|
// Recompose the full reference from the same (provider, group, name)
|
||||||
// dispatcher derives (name, provider, group) from the composed
|
// tuple the dispatcher derived, then do an exact OrdinalIgnoreCase
|
||||||
// reference, so the object-rooted tag name is always a substring of the
|
// lookup in targetsByReference. This avoids the false-positive that a
|
||||||
// target's AlarmFullReference.
|
// substring scan would produce when one alarm name is a prefix of
|
||||||
foreach (AlarmSubtagTarget target in targetsByReference.Values)
|
// another (e.g. "Level.Hi" matching "Level.HiHi").
|
||||||
{
|
string composed = AlarmRecordTransitionMapper.ComposeFullReference(providerName, groupName, alarmName);
|
||||||
string reference = target.AlarmFullReference ?? string.Empty;
|
targetsByReference.TryGetValue(composed, out AlarmSubtagTarget? target);
|
||||||
if (reference.IndexOf(alarmName!, StringComparison.OrdinalIgnoreCase) >= 0)
|
return target;
|
||||||
{
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int WriteAckComment(AlarmSubtagTarget target, string ackComment)
|
private int WriteAckComment(AlarmSubtagTarget target, string ackComment)
|
||||||
|
|||||||
Reference in New Issue
Block a user