worker(alarms): fix net48 build (init->set, usings), token-boundary name parse, acked latch, dup-address guard, tests

This commit is contained in:
Joseph Doherty
2026-06-13 09:05:58 -04:00
parent 348ab16456
commit b10e103bcf
4 changed files with 138 additions and 25 deletions
@@ -33,6 +33,58 @@ public sealed class SubtagAlarmStateMachineTests
Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
Assert.Equal("Galaxy", e.Record.ProviderName);
Assert.Equal("Area", e.Record.Group);
}
[Fact]
public void ActiveFalseToTrue_NoProviderBang_UsesWholeReferenceAsTagName()
{
var target = new AlarmSubtagTarget
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = string.Empty,
ActiveSubtag = "Tank01.Level.HiHi.active",
};
var sm = new SubtagAlarmStateMachine(new[] { target });
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
var e = Assert.Single(events);
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName);
Assert.Equal(string.Empty, e.Record.ProviderName);
Assert.Equal(string.Empty, e.Record.Group);
}
[Fact]
public void OutOfOrderAckThenClear_StillEmitsAckRtn()
{
var sm = new SubtagAlarmStateMachine(new[] { Target() });
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
sm.Apply("Tank01.Level.HiHi.active", true, ts);
sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(2));
// Out-of-order un-ack arrives before the active=false clear.
sm.Apply("Tank01.Level.HiHi.acked", false, ts.AddSeconds(3));
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
var e = Assert.Single(events);
Assert.Equal(MxAlarmStateKind.AckRtn, e.Record.State);
}
[Fact]
public void DuplicateActiveSubtag_Throws()
{
var first = new AlarmSubtagTarget
{
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
ActiveSubtag = "Shared.active",
};
var second = new AlarmSubtagTarget
{
AlarmFullReference = "Galaxy!Area.Tank02.Level.HiHi",
SourceObjectReference = "Tank02",
ActiveSubtag = "Shared.active",
};
Assert.Throws<ArgumentException>(() => new SubtagAlarmStateMachine(new[] { first, second }));
}
[Fact]
@@ -1,31 +1,21 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
public sealed class SubtagValueChange
{
/// <summary>Gets the MXAccess item address (subtag reference) whose value changed.</summary>
public string ItemAddress { get; init; } = string.Empty;
/// <summary>Gets the new value, as delivered by MXAccess (boxed COM variant or test value).</summary>
public object? Value { get; init; }
/// <summary>Gets the UTC timestamp the change was observed.</summary>
public DateTime TimestampUtc { get; init; }
}
/// <summary>
/// Advises a set of MXAccess subtag addresses and surfaces value changes.
/// The production implementation owns its own LMXProxyServerClass (later task);
/// tests substitute a fake that pushes SubtagValueChange values.
/// </summary>
public interface ISubtagAlarmSource : System.IDisposable
public interface ISubtagAlarmSource : IDisposable
{
/// <summary>Raised when an advised subtag reports a new value.</summary>
event System.EventHandler<SubtagValueChange>? ValueChanged;
event EventHandler<SubtagValueChange>? ValueChanged;
/// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary>
/// <param name="itemAddresses">The subtag references to advise.</param>
void Advise(System.Collections.Generic.IReadOnlyCollection<string> itemAddresses);
void Advise(IReadOnlyCollection<string> itemAddresses);
/// <summary>Writes a value to the named MXAccess subtag (e.g. an ack request).</summary>
/// <param name="itemAddress">The subtag reference to write.</param>
@@ -15,6 +15,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// consumer's UNACK_ALM / ACK_ALM / UNACK_RTN / ACK_RTN transitions.
/// This type performs no COM work and is fully unit-testable.
/// </summary>
/// <remarks>
/// This type has STA affinity: it is mutated and read on the worker STA
/// thread only and is not thread-safe. The per-target
/// <see cref="AlarmSubtagTarget.AckCommentSubtag"/> is a write-only ack
/// target and is deliberately not bound for observation. The synthesized
/// <see cref="MxAlarmSnapshotRecord.AlarmGuid"/> is left at its default
/// value: in subtag mode acks are correlated by name/comment rather than
/// by GUID, and a synthetic GUID is assigned by a later component.
/// </remarks>
public sealed class SubtagAlarmStateMachine
{
private static readonly IReadOnlyList<MxAlarmTransitionEvent> NoEvents = Array.Empty<MxAlarmTransitionEvent>();
@@ -107,20 +116,32 @@ public sealed class SubtagAlarmStateMachine
MxAlarmStateKind previous = state.DerivedState();
state.Active = true;
state.Acked = false;
state.AckedDuringEpisode = false;
state.FirstRaiseUtc = timestampUtc;
return Single(state, MxAlarmStateKind.UnackAlm, previous, timestampUtc);
}
else
{
// true -> false: alarm returned to normal. State depends on ack.
// true -> false: alarm returned to normal. The return kind reflects
// whether the alarm was acknowledged at any point during the active
// episode, not just its instantaneous Acked flag (see remarks).
MxAlarmStateKind previous = state.DerivedState();
MxAlarmStateKind kind = state.Acked ? MxAlarmStateKind.AckRtn : MxAlarmStateKind.UnackRtn;
MxAlarmStateKind kind = state.AckedDuringEpisode ? MxAlarmStateKind.AckRtn : MxAlarmStateKind.UnackRtn;
state.Active = false;
state.Acked = false;
state.AckedDuringEpisode = false;
return Single(state, kind, previous, timestampUtc);
}
}
/// <remarks>
/// <see cref="AlarmState.AckedDuringEpisode"/> latches that the alarm was
/// acknowledged at some point while active. MXAccess does not guarantee
/// that the acked subtag update arrives before the active=false update on
/// clear, so an out-of-order un-ack (acked true->false) must not lose the
/// fact that the episode was acknowledged; the latch ensures the clear
/// still emits ACK_RTN rather than UNACK_RTN.
/// </remarks>
private IReadOnlyList<MxAlarmTransitionEvent> ApplyAcked(AlarmState state, bool newAcked, DateTime timestampUtc)
{
if (newAcked == state.Acked)
@@ -130,12 +151,16 @@ public sealed class SubtagAlarmStateMachine
state.Acked = newAcked;
// Only a true ack of a currently-active alarm produces an ACK_ALM transition.
// Only a true ack of a currently-active alarm produces an ACK_ALM
// transition; the latch records that the episode was acked at some point.
if (newAcked && state.Active)
{
state.AckedDuringEpisode = true;
return Single(state, MxAlarmStateKind.AckAlm, MxAlarmStateKind.UnackAlm, timestampUtc);
}
// acked true->false while active: do NOT clear the latch (the episode was
// already acked); emit nothing.
return NoEvents;
}
@@ -159,6 +184,13 @@ public sealed class SubtagAlarmStateMachine
{
if (!string.IsNullOrEmpty(address))
{
if (_bindingsByAddress.ContainsKey(address!))
{
throw new ArgumentException(
$"Duplicate subtag item address '{address}' is bound to more than one alarm target.",
nameof(address));
}
_bindingsByAddress[address!] = new SubtagBinding(state, role);
}
}
@@ -254,6 +286,13 @@ public sealed class SubtagAlarmStateMachine
public bool Acked { get; set; }
/// <summary>
/// Latches that the alarm was acknowledged at some point during the
/// current active episode, so a clear emits ACK_RTN even when the
/// un-ack subtag arrives before the active=false update.
/// </summary>
public bool AckedDuringEpisode { get; set; }
public int Priority { get; set; }
public DateTime FirstRaiseUtc { get; set; }
@@ -307,15 +346,31 @@ public sealed class SubtagAlarmStateMachine
string group = string.Empty;
if (source.Length > 0)
{
int objIndex = afterBang.IndexOf(source, StringComparison.OrdinalIgnoreCase);
if (objIndex > 0)
// Match the source on dot-delimited segment boundaries so a source
// such as "Tank" does not match mid-token inside "Tank01" and a
// source "Area" does not match inside "TankArea".
string[] segments = afterBang.Split('.');
int segmentIndex = -1;
for (int i = 0; i < segments.Length; i++)
{
// Everything before the object (minus a trailing '.') is the area/group.
group = afterBang.Substring(0, objIndex).TrimEnd('.');
tagName = afterBang.Substring(objIndex);
if (string.Equals(segments[i], source, StringComparison.OrdinalIgnoreCase))
{
segmentIndex = i;
break;
}
}
else if (objIndex == 0)
if (segmentIndex > 0)
{
// Everything before the object is the area/group; the object
// and everything after it is the object-rooted tag name.
group = string.Join(".", segments, 0, segmentIndex);
tagName = string.Join(".", segments, segmentIndex, segments.Length - segmentIndex);
}
else
{
// segmentIndex == 0 (source is the first segment) or not found:
// no leading area to strip, so the tag name is the whole afterBang.
tagName = afterBang;
}
}
@@ -0,0 +1,16 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
public sealed class SubtagValueChange
{
/// <summary>Gets or sets the MXAccess item address (subtag reference) whose value changed.</summary>
public string ItemAddress { get; set; } = string.Empty;
/// <summary>Gets or sets the new value, as delivered by MXAccess (boxed COM variant or test value).</summary>
public object? Value { get; set; }
/// <summary>Gets or sets the UTC timestamp the change was observed.</summary>
public DateTime TimestampUtc { get; set; }
}