worker(alarms): fix net48 build (init->set, usings), token-boundary name parse, acked latch, dup-address guard, tests
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user