b9fb0dd720
Review found AlarmDispatcher.SnapshotActiveAlarms reading the snapshot and the truncation verdict through two independent lock acquisitions, defended by a comment claiming read-order made a race "widen only, never narrow". That claim was false: a not-truncated -> truncated poll landing between the two reads pairs a stale false with a capped snapshot, which is exactly the false all-clear the feature exists to prevent. It was safe only because AlarmCommandHandler STA-serializes consumer calls — an accident of the call graph, not an invariant. Made the invariant structural instead of documented. IMxAccessAlarmConsumer now exposes ONE accessor, `IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms( out bool truncated)`, which implementations must satisfy from a single acquisition of the lock guarding the retained snapshot — mirroring the write side, where FoldFetch already updates snapshot and verdict together. The separate LastSnapshotTruncated property is gone from every layer, so there is no second read left to pair badly. `out` over a result struct follows the file's established idiom (FoldFetch, ParseSnapshotXml). The same threading applies one level up: IAlarmCommandHandler.QueryActive now carries `out bool snapshotTruncated`, so MxAccessCommandExecutor stamps the reply payload from the value the records were stamped with rather than reading the state a second time. Direct tests for the three hops that were only covered end-to-end: - AlarmDispatcherTests: truncated consumer snapshot stamps FromTruncatedSnapshot on every mapped record, with a complete-snapshot control, plus an assertion that the independent per-record Degraded flag is not dragged along. - AlarmCommandHandlerTests: the verdict delegates through the dispatcher (Theory over both values), and survives a prefix filter that removes every record — the case the per-record flag cannot cover. - AlarmCommandExecutorTests: the reply payload's SnapshotTruncated comes from the handler (Theory over both values), including the zero-record case. The WnWrapAlarmConsumer truncation tests now assert through SnapshotActiveAlarms(out ...) rather than an internal field, because the pairing is the contract. Also: GatewayAlarmMonitor's _snapshotTruncated comment now says "as of the last full reconcile" rather than implying it tracks the current _alarms contents, which live transitions keep moving via ApplyTransition between passes. Detection heuristic still untouched (fetchedRecordCount >= maxAlarmsPerFetch); no @COUNT parsing, per docs/AlarmProbeFindings.md. Still additive gateway metadata about our fetch mechanics, not MXAccess behavior — not a parity deviation, and no event is synthesized. Gateway: NonWindows.slnx builds clean (0 warnings); ~Alarm filter 107/107 pass. Worker + Worker.Tests are windev-gated; the signature change was reviewed by inspection across all 7 IMxAccessAlarmConsumer implementers, all 3 IAlarmCommandHandler implementers, and every call site.
804 lines
35 KiB
C#
804 lines
35 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Unit-test coverage for <see cref="WnWrapAlarmConsumer"/>'s pure
|
|
/// parsing helpers — XML payload → <see cref="MxAlarmSnapshotRecord"/>
|
|
/// dictionary, and the 32-char-hex GUID round-trip. The COM-side
|
|
/// polling loop is verified separately by the Skip-gated
|
|
/// <c>WnWrapConsumerProbeTests</c> on a live AVEVA install.
|
|
/// </summary>
|
|
public sealed class WnWrapAlarmConsumerXmlTests
|
|
{
|
|
/// <summary>Captured XML from the dev rig (probe run 2026-05-01).</summary>
|
|
private const string SingleAlarmActiveXml =
|
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
|
"<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>" +
|
|
"<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
|
|
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>" +
|
|
"<PROVIDER_NAME>Galaxy</PROVIDER_NAME>" +
|
|
"<GROUP>TestArea</GROUP>" +
|
|
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>" +
|
|
"<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
|
|
"<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>" +
|
|
"<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
|
|
"<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT></ALARM>" +
|
|
"</ALARM_RECORDS>";
|
|
|
|
private const string EmptyXml =
|
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
|
|
|
|
/// <summary>Verifies that empty XML payload returns an empty dictionary.</summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary()
|
|
{
|
|
var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml);
|
|
Assert.Empty(records);
|
|
}
|
|
|
|
/// <summary>Verifies that null or whitespace payload returns an empty dictionary.</summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary()
|
|
{
|
|
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(""));
|
|
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
|
|
}
|
|
|
|
/// <summary>Verifies that single alarm XML payload decodes the record correctly.</summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithSingleActiveAlarm_DecodesRecord()
|
|
{
|
|
var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml);
|
|
|
|
Assert.Single(records);
|
|
Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
|
|
var record = records[expectedGuid];
|
|
Assert.Equal(expectedGuid, record.AlarmGuid);
|
|
Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode);
|
|
Assert.Equal("Galaxy", record.ProviderName);
|
|
Assert.Equal("TestArea", record.Group);
|
|
Assert.Equal("TestMachine_001.TestAlarm001", record.TagName);
|
|
Assert.Equal("DSC", record.Type);
|
|
Assert.Equal("true", record.Value);
|
|
Assert.Equal("true", record.Limit);
|
|
Assert.Equal(500, record.Priority);
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
|
|
Assert.Equal("Test alarm #1", record.AlarmComment);
|
|
Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind);
|
|
// 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC.
|
|
Assert.Equal(17, record.TransitionTimestampUtc.Hour);
|
|
Assert.Equal(26, record.TransitionTimestampUtc.Minute);
|
|
}
|
|
|
|
/// <summary>Verifies that invalid GUIDs in XML payload are silently dropped.</summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords()
|
|
{
|
|
string xml = SingleAlarmActiveXml.Replace(
|
|
"<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>",
|
|
"<GUID>not-a-guid</GUID>");
|
|
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml));
|
|
}
|
|
|
|
/// <summary>Verifies that dashless 32-character hex GUIDs parse correctly.</summary>
|
|
/// <param name="hex">The dashless hex string.</param>
|
|
/// <param name="expected">The expected canonical GUID form.</param>
|
|
[Theory]
|
|
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
|
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
|
|
public void TryParseHexGuid_WithDashless32CharHex_Parses(string hex, string expected)
|
|
{
|
|
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
|
Assert.Equal(new Guid(expected), guid);
|
|
}
|
|
|
|
/// <summary>Verifies that canonical dashed GUID format is accepted.</summary>
|
|
/// <param name="canonical">The canonical GUID form.</param>
|
|
[Theory]
|
|
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
|
public void TryParseHexGuid_WithCanonicalDashedForm_Accepts(string canonical)
|
|
{
|
|
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid));
|
|
Assert.Equal(new Guid(canonical), guid);
|
|
}
|
|
|
|
/// <summary>Verifies that invalid GUID inputs are rejected.</summary>
|
|
/// <param name="hex">The invalid GUID hex string.</param>
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("nope")]
|
|
[InlineData("0123456789ABCDEF")] // too short
|
|
[InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long
|
|
public void TryParseHexGuid_WithInvalidInput_Rejects(string? hex)
|
|
{
|
|
Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
|
Assert.Equal(Guid.Empty, guid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The consumer must own no internal
|
|
/// <see cref="Timer"/>. A thread-pool timer calling the
|
|
/// apartment-threaded wnwrap COM object off its owning STA can
|
|
/// deadlock on cross-apartment marshaling, so the timer field and
|
|
/// callback must not exist on the type.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WnWrapAlarmConsumer_ByReflection_HasNoInternalTimerField()
|
|
{
|
|
FieldInfo[] fields = typeof(WnWrapAlarmConsumer)
|
|
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(Timer));
|
|
Assert.Null(typeof(WnWrapAlarmConsumer).GetMethod(
|
|
"OnPoll",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
|
|
}
|
|
|
|
/// <summary>
|
|
/// No public constructor may accept a
|
|
/// poll-interval parameter. A non-zero poll interval was the only
|
|
/// way to arm the off-STA timer; removing the parameter makes the
|
|
/// footgun structurally unreachable.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WnWrapAlarmConsumer_ByReflection_ExposesNoPollIntervalConstructorParameter()
|
|
{
|
|
foreach (ConstructorInfo constructor in typeof(WnWrapAlarmConsumer)
|
|
.GetConstructors(BindingFlags.Instance | BindingFlags.Public))
|
|
{
|
|
Assert.DoesNotContain(
|
|
constructor.GetParameters(),
|
|
parameter => parameter.Name is not null
|
|
&& parameter.Name.IndexOf("poll", StringComparison.OrdinalIgnoreCase) >= 0);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the "new alarm sighting" branch of
|
|
/// <see cref="WnWrapAlarmConsumer.ComputeTransitions"/>. A GUID
|
|
/// that appears in <c>next</c> but not in <c>previous</c> must
|
|
/// produce exactly one transition with
|
|
/// <see cref="MxAlarmStateKind.Unspecified"/> as the previous
|
|
/// state — the proto layer relies on this sentinel to map a
|
|
/// first sighting to a <c>Raise</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ComputeTransitions_WhenAlarmIsNewInNextSnapshot_EmitsTransitionWithUnspecifiedPreviousState()
|
|
{
|
|
Guid alarmGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new();
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
|
{
|
|
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
|
|
|
MxAlarmTransitionEvent single = Assert.Single(transitions);
|
|
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, single.Record.State);
|
|
Assert.Equal(MxAlarmStateKind.Unspecified, single.PreviousState);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the "state unchanged" branch. A GUID
|
|
/// present in both snapshots with identical
|
|
/// <see cref="MxAlarmSnapshotRecord.State"/> must produce no
|
|
/// transition — a regression that emits a transition every poll
|
|
/// regardless of state change would slip through without this
|
|
/// test.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ComputeTransitions_WhenAlarmStateUnchanged_EmitsNoTransition()
|
|
{
|
|
Guid alarmGuid = Guid.NewGuid();
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
|
{
|
|
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
|
{
|
|
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
|
|
|
Assert.Empty(transitions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the "state changed" branch. A GUID
|
|
/// present in both snapshots with a different state must produce
|
|
/// one transition carrying the prior state so the proto layer
|
|
/// can distinguish e.g. <c>UnackAlm</c>→<c>AckAlm</c>
|
|
/// (Acknowledge) from <c>Unspecified</c>→<c>UnackAlm</c> (Raise).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ComputeTransitions_WhenAlarmStateChanged_EmitsTransitionWithPriorState()
|
|
{
|
|
Guid alarmGuid = Guid.NewGuid();
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
|
{
|
|
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
|
{
|
|
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.AckAlm),
|
|
};
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
|
|
|
MxAlarmTransitionEvent single = Assert.Single(transitions);
|
|
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
|
|
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the "alarm cleared from the active set"
|
|
/// branch. AVEVA drops cleared alarms from
|
|
/// <c>GetXmlCurrentAlarms2</c>'s active set rather than emitting a
|
|
/// transition record. A GUID present in
|
|
/// <c>previous</c> but absent from <c>next</c> must therefore
|
|
/// produce no transition; the diff treats disappearance as an
|
|
/// implicit clear that the proto layer recognises by the missing
|
|
/// GUID, not by an emitted event.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ComputeTransitions_WhenAlarmDroppedFromActiveSet_EmitsNoTransition()
|
|
{
|
|
Guid alarmGuid = Guid.NewGuid();
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
|
{
|
|
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = new();
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
|
|
|
Assert.Empty(transitions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the multi-alarm fan-out. Multiple
|
|
/// simultaneous transitions (new + changed + unchanged + dropped)
|
|
/// in one snapshot must produce exactly the changed and new
|
|
/// entries — not the unchanged and not the dropped.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ComputeTransitions_WithMixedDelta_EmitsOnlyNewAndChangedTransitions()
|
|
{
|
|
Guid newGuid = Guid.NewGuid();
|
|
Guid changedGuid = Guid.NewGuid();
|
|
Guid unchangedGuid = Guid.NewGuid();
|
|
Guid droppedGuid = Guid.NewGuid();
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
|
|
{
|
|
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.UnackAlm),
|
|
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
|
|
[droppedGuid] = NewRecord(droppedGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
|
{
|
|
[newGuid] = NewRecord(newGuid, MxAlarmStateKind.UnackAlm),
|
|
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.AckAlm),
|
|
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
|
|
};
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
|
|
|
|
Assert.Equal(2, transitions.Count);
|
|
|
|
MxAlarmTransitionEvent newTransition = Assert.Single(
|
|
transitions,
|
|
t => t.Record.AlarmGuid == newGuid);
|
|
Assert.Equal(MxAlarmStateKind.Unspecified, newTransition.PreviousState);
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, newTransition.Record.State);
|
|
|
|
MxAlarmTransitionEvent changedTransition = Assert.Single(
|
|
transitions,
|
|
t => t.Record.AlarmGuid == changedGuid);
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, changedTransition.PreviousState);
|
|
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Truncation cliff (Task 23). GetXmlCurrentAlarms2 caps its reply at
|
|
// maxAlmCnt with no "more available" flag. Before the guard, a capped fetch
|
|
// shrank the retained snapshot, and every alarm past the cap vanished from
|
|
// SnapshotActiveAlarms — which the gateway's reconcile pass reads as a
|
|
// Clear and broadcasts to every StreamAlarms subscriber.
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// A fetch that comes back holding exactly the cap must be treated as
|
|
/// truncated; anything below the cap must not.
|
|
/// </summary>
|
|
/// <param name="fetchedRecordCount">Records the reply carried.</param>
|
|
/// <param name="maxAlarmsPerFetch">The cap passed to the fetch.</param>
|
|
/// <param name="expected">Whether the reply must read as truncated.</param>
|
|
[Theory]
|
|
[InlineData(1024, 1024, true)]
|
|
[InlineData(1023, 1024, false)]
|
|
[InlineData(0, 1024, false)]
|
|
[InlineData(64, 64, true)]
|
|
public void IsTruncatedFetch_AtOrAboveCap_IsTruncated(
|
|
int fetchedRecordCount,
|
|
int maxAlarmsPerFetch,
|
|
bool expected)
|
|
{
|
|
Assert.Equal(
|
|
expected,
|
|
WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch));
|
|
}
|
|
|
|
/// <summary>
|
|
/// THE truncation-correctness test. A 1024-record fetch against a
|
|
/// 1024 cap that does not mention a known-active alarm must NOT evict
|
|
/// that alarm from the retained snapshot: the snapshot is what
|
|
/// <see cref="WnWrapAlarmConsumer.SnapshotActiveAlarms"/> returns and
|
|
/// what the gateway's reconcile diffs its cache against, so an
|
|
/// eviction here is a Clear broadcast for an alarm that never cleared.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApplySnapshotUpdate_WhenFetchTruncated_RetainsAlarmMissingFromCappedFetch()
|
|
{
|
|
const int Cap = 1024;
|
|
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
|
|
|
|
string xml = BuildAlarmXml(Cap);
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
|
|
|
Assert.Equal(Cap, fetchedRecordCount);
|
|
Assert.Equal(Cap, next.Count);
|
|
Assert.False(next.ContainsKey(missingGuid));
|
|
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
|
|
{
|
|
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
|
|
// The diff itself never emits a Clear for a disappearance — the Clear
|
|
// is the eviction, one level up. Pin both halves.
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
|
|
Assert.DoesNotContain(transitions, t => t.Record.AlarmGuid == missingGuid);
|
|
|
|
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
|
|
|
|
// NO Clear: the alarm the capped fetch had no room to mention survives,
|
|
// so the gateway's reconcile still sees it active.
|
|
Assert.True(snapshot.ContainsKey(missingGuid));
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, snapshot[missingGuid].State);
|
|
Assert.Equal(Cap + 1, snapshot.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The control case: the same missing alarm against a sub-cap fetch
|
|
/// must still be evicted, because a complete fetch IS authoritative
|
|
/// about absence. Without this, the truncation guard would have
|
|
/// silently disabled clears altogether.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApplySnapshotUpdate_WhenFetchBelowCap_EvictsAlarmMissingFromFetch()
|
|
{
|
|
const int Cap = 1024;
|
|
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
|
|
|
|
string xml = BuildAlarmXml(Cap - 1);
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
|
|
|
Assert.Equal(Cap - 1, fetchedRecordCount);
|
|
Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
|
|
{
|
|
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
|
|
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: false);
|
|
|
|
// Clear as today: the alarm drops out of the snapshot, and the
|
|
// gateway's reconcile turns that absence into a Clear.
|
|
Assert.False(snapshot.ContainsKey(missingGuid));
|
|
Assert.Equal(Cap - 1, snapshot.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A truncated fetch must still apply the alarms it DID carry — the
|
|
/// guard suppresses eviction, not the update. An alarm whose state
|
|
/// changed inside a capped reply still transitions.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApplySnapshotUpdate_WhenFetchTruncated_StillAppliesPresentAlarms()
|
|
{
|
|
Guid presentGuid = new Guid("22222222-2222-2222-2222-222222222222");
|
|
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
|
|
{
|
|
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.UnackAlm),
|
|
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
|
|
};
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
|
{
|
|
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.AckAlm),
|
|
};
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
|
|
|
|
MxAlarmTransitionEvent single = Assert.Single(transitions);
|
|
Assert.Equal(presentGuid, single.Record.AlarmGuid);
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
|
|
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
|
|
|
|
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
|
|
|
|
Assert.Equal(MxAlarmStateKind.AckAlm, snapshot[presentGuid].State);
|
|
Assert.True(snapshot.ContainsKey(missingGuid));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Truncation is detected from the raw ALARM-element count, not the
|
|
/// parsed dictionary size. A record dropped for a malformed GUID still
|
|
/// consumed a slot in the capped reply, so counting only survivors
|
|
/// would let a truncated fetch pass as complete and re-open the cliff.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_CountsRawAlarmElements_IncludingRecordsDroppedForBadGuid()
|
|
{
|
|
const int Cap = 64;
|
|
string xml = BuildAlarmXml(Cap).Replace(
|
|
"<GUID>00000000000000000000000000000001</GUID>",
|
|
"<GUID>not-a-guid</GUID>");
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
|
|
|
Assert.Equal(Cap, fetchedRecordCount);
|
|
Assert.Equal(Cap - 1, records.Count);
|
|
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The per-fetch cap comes from the launcher-set environment variable;
|
|
/// a missing, unparseable, or below-floor value must fall back to the
|
|
/// 1024 default rather than throw. A bad environment value must never
|
|
/// stop the alarm consumer from starting.
|
|
/// </summary>
|
|
/// <param name="environmentValue">Raw environment value under test.</param>
|
|
/// <param name="expected">Expected resolved cap.</param>
|
|
[Theory]
|
|
[InlineData(null, 1024)]
|
|
[InlineData("", 1024)]
|
|
[InlineData("not-a-number", 1024)]
|
|
[InlineData("0", 1024)]
|
|
[InlineData("-5", 1024)]
|
|
[InlineData("63", 1024)]
|
|
[InlineData("64", 64)]
|
|
[InlineData("4096", 4096)]
|
|
[InlineData("65536", 65536)]
|
|
// Above the ceiling: the x86 worker materializes the whole reply as one
|
|
// BSTR plus an XmlDocument, so an unbounded cap is an OOM on the STA.
|
|
[InlineData("65537", 1024)]
|
|
[InlineData("2147483647", 1024)]
|
|
public void ResolveMaxAlarmsPerFetch_WithEnvironmentValue_FallsBackToDefaultWhenUnusable(
|
|
string? environmentValue,
|
|
int expected)
|
|
{
|
|
string? original = Environment.GetEnvironmentVariable(
|
|
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName);
|
|
try
|
|
{
|
|
Environment.SetEnvironmentVariable(
|
|
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
|
|
environmentValue);
|
|
|
|
Assert.Equal(expected, WnWrapAlarmConsumer.ResolveMaxAlarmsPerFetch());
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(
|
|
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
|
|
original);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A galaxy parked above the cap truncates on every poll, so the
|
|
/// warning must be throttled: two truncated polls inside one interval
|
|
/// produce exactly one line, and the next one only after the full
|
|
/// interval has elapsed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ShouldWarnTruncation_ThrottlesConsecutiveTruncatedPollsToOneWarningPerInterval()
|
|
{
|
|
// Seeded so the very first truncated poll always warns.
|
|
const long NeverWarned = -60_000;
|
|
|
|
// Poll 1 at t=0: warns, and records t=0 as the last warning.
|
|
Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(0, NeverWarned));
|
|
|
|
// Poll 2 half a second later (the default cadence): suppressed.
|
|
Assert.False(WnWrapAlarmConsumer.ShouldWarnTruncation(500, 0));
|
|
|
|
// Still suppressed just shy of the interval...
|
|
Assert.False(WnWrapAlarmConsumer.ShouldWarnTruncation(59_999, 0));
|
|
|
|
// ...and allowed again exactly on it.
|
|
Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(60_000, 0));
|
|
Assert.True(WnWrapAlarmConsumer.ShouldWarnTruncation(120_000, 60_000));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a well-formed ALARM_RECORDS payload with
|
|
/// <paramref name="count"/> distinct alarms. GUIDs are the dashless
|
|
/// 32-char hex form wnwrap actually emits, numbered from 1 so a test
|
|
/// can name one (e.g. ...0001) to corrupt.
|
|
/// </summary>
|
|
/// <param name="count">Number of ALARM elements to emit.</param>
|
|
/// <returns>The XML payload.</returns>
|
|
private static string BuildAlarmXml(int count)
|
|
{
|
|
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
|
sb.Append("<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"")
|
|
.Append(count)
|
|
.Append("\">");
|
|
for (int index = 1; index <= count; index++)
|
|
{
|
|
sb.Append("<ALARM><GUID>")
|
|
.Append(index.ToString("X32", System.Globalization.CultureInfo.InvariantCulture))
|
|
.Append("</GUID>")
|
|
.Append("<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>")
|
|
.Append("<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>")
|
|
.Append("<PROVIDER_NODE>TEST-NODE</PROVIDER_NODE>")
|
|
.Append("<PROVIDER_NAME>Galaxy</PROVIDER_NAME>")
|
|
.Append("<GROUP>TestArea</GROUP>")
|
|
.Append("<TAGNAME>TestMachine_")
|
|
.Append(index.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
|
.Append(".TestAlarm</TAGNAME>")
|
|
.Append("<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>")
|
|
.Append("<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>")
|
|
.Append("<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>")
|
|
.Append("<ALARM_COMMENT>Test alarm</ALARM_COMMENT></ALARM>");
|
|
}
|
|
sb.Append("</ALARM_RECORDS>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Single-pass parse parity (Task 23). The per-field SelectSingleNode calls
|
|
// were replaced by one walk over ChildNodes; these pin the semantics that
|
|
// walk has to reproduce.
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// An absent child element and a present-but-empty one must both yield
|
|
/// <see cref="string.Empty"/>, exactly as
|
|
/// <c>SelectSingleNode(name)?.InnerText ?? string.Empty</c> did.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithAbsentAndEmptyChildren_YieldsEmptyStrings()
|
|
{
|
|
string xml =
|
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
|
"<TAGNAME></TAGNAME>" +
|
|
"<STATE>UNACK_ALM</STATE></ALARM>" +
|
|
"</ALARM_RECORDS>";
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
|
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
|
|
|
|
Assert.Equal(string.Empty, record.TagName); // present but empty
|
|
Assert.Equal(string.Empty, record.ProviderNode); // absent
|
|
Assert.Equal(string.Empty, record.Value); // absent
|
|
Assert.Equal(string.Empty, record.AlarmComment); // absent
|
|
Assert.Equal(0, record.Priority); // absent → ParseInt("") → 0
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Duplicate child elements must resolve to the FIRST occurrence —
|
|
/// <c>SelectSingleNode</c> returned the first match, so a last-wins
|
|
/// walk would silently change which value a malformed payload yields.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithDuplicateChildElements_TakesFirstOccurrence()
|
|
{
|
|
string xml =
|
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
|
"<TAGNAME>First</TAGNAME><TAGNAME>Second</TAGNAME>" +
|
|
"<PRIORITY>100</PRIORITY><PRIORITY>900</PRIORITY>" +
|
|
"<STATE>UNACK_ALM</STATE></ALARM>" +
|
|
"</ALARM_RECORDS>";
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
|
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
|
|
|
|
Assert.Equal("First", record.TagName);
|
|
Assert.Equal(100, record.Priority);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Field names are matched ordinally and case-sensitively, as the
|
|
/// XPath node test was. A lowercase element must not populate the
|
|
/// field its uppercase counterpart owns.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithWrongCaseChildElement_DoesNotPopulateField()
|
|
{
|
|
string xml =
|
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
|
"<tagname>ShouldBeIgnored</tagname>" +
|
|
"<STATE>UNACK_ALM</STATE></ALARM>" +
|
|
"</ALARM_RECORDS>";
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
|
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
|
|
|
|
Assert.Equal(string.Empty, record.TagName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Non-element children (whitespace text, comments, CDATA) must be
|
|
/// skipped rather than matched by name, and a nested element must
|
|
/// contribute its InnerText exactly as <c>InnerText</c> always did.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithCommentsAndWhitespace_IgnoresNonElementChildren()
|
|
{
|
|
string xml =
|
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">\n" +
|
|
" <ALARM>\n" +
|
|
" <!-- a comment -->\n" +
|
|
" <GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>\n" +
|
|
" <TAGNAME>TestMachine.TestAlarm</TAGNAME>\n" +
|
|
" <STATE>UNACK_ALM</STATE>\n" +
|
|
" </ALARM>\n" +
|
|
"</ALARM_RECORDS>";
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
|
|
|
Assert.Equal(1, fetchedRecordCount);
|
|
MxAlarmSnapshotRecord record = Assert.Single(records).Value;
|
|
Assert.Equal("TestMachine.TestAlarm", record.TagName);
|
|
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Degraded-status signal. The truncation guard above keeps a capped fetch
|
|
// from broadcasting phantom Clears, but it does so silently: the retained
|
|
// snapshot simply stops shrinking. The truncation verdict SnapshotActiveAlarms
|
|
// hands back alongside the records is what makes that suppression visible to
|
|
// the QueryActiveAlarms reply and, through it, the dashboard banner — so its
|
|
// set/reset behaviour is the contract, not detail.
|
|
//
|
|
// These assert through SnapshotActiveAlarms(out ...) rather than any internal
|
|
// field, because the pairing IS the contract: records and verdict must come
|
|
// out of one call, produced under one lock acquisition.
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// A capped fetch sets the truncation verdict handed out with the
|
|
/// snapshot. Without this the signal never leaves the consumer and the
|
|
/// reply builder stamps a complete-looking snapshot over a capped one.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SnapshotActiveAlarms_AfterTruncatedFetch_ReportsTruncated()
|
|
{
|
|
const int Cap = 8;
|
|
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
|
|
|
|
consumer.SnapshotActiveAlarms(out bool truncatedBeforeAnyFetch);
|
|
Assert.False(truncatedBeforeAnyFetch);
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount);
|
|
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
|
|
|
consumer.FoldFetch(next, truncated: true, out int retainedCount);
|
|
|
|
IReadOnlyList<MxAlarmSnapshotRecord> snapshot =
|
|
consumer.SnapshotActiveAlarms(out bool truncated);
|
|
|
|
Assert.True(truncated);
|
|
Assert.Equal(Cap, retainedCount);
|
|
// The verdict describes THIS set — assert they arrive together, not just
|
|
// that the boolean flipped somewhere.
|
|
Assert.Equal(Cap, snapshot.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// THE reset test. A sub-cap fetch is complete, so it restores absence
|
|
/// authority and must clear the verdict. Latching it instead would leave
|
|
/// the operator banner asserting "snapshot may be incomplete" forever
|
|
/// after a single burst above the cap, which trains operators to ignore
|
|
/// it — the opposite of what the signal is for.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SnapshotActiveAlarms_AfterSubCapFetchFollowingTruncation_ReportsComplete()
|
|
{
|
|
const int Cap = 8;
|
|
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> capped =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
|
|
consumer.FoldFetch(capped, truncated: true, out _);
|
|
consumer.SnapshotActiveAlarms(out bool truncatedAfterCappedFetch);
|
|
Assert.True(truncatedAfterCappedFetch);
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> complete =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount);
|
|
Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
|
|
|
consumer.FoldFetch(complete, truncated: false, out int retainedCount);
|
|
|
|
IReadOnlyList<MxAlarmSnapshotRecord> snapshot =
|
|
consumer.SnapshotActiveAlarms(out bool truncated);
|
|
|
|
Assert.False(truncated);
|
|
// The complete fetch also replaced the snapshot wholesale, which is what
|
|
// makes it authoritative about absence — pinned here so a future change
|
|
// cannot clear the verdict while keeping the merge semantics.
|
|
Assert.Equal(Cap - 1, retainedCount);
|
|
Assert.Equal(Cap - 1, snapshot.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Consecutive capped fetches keep the verdict set. It is per-fetch state,
|
|
/// not an edge-triggered one-shot: an operator arriving mid-burst must
|
|
/// still see the caveat.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SnapshotActiveAlarms_WithConsecutiveTruncatedFetches_KeepsReportingTruncated()
|
|
{
|
|
const int Cap = 8;
|
|
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
|
|
|
|
for (int pass = 0; pass < 3; pass++)
|
|
{
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> capped =
|
|
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
|
|
consumer.FoldFetch(capped, truncated: true, out _);
|
|
|
|
consumer.SnapshotActiveAlarms(out bool truncated);
|
|
Assert.True(truncated);
|
|
}
|
|
}
|
|
|
|
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
|
|
{
|
|
return new MxAlarmSnapshotRecord
|
|
{
|
|
AlarmGuid = guid,
|
|
State = state,
|
|
TagName = "TestMachine.TestAlarm",
|
|
ProviderNode = "TEST-NODE",
|
|
ProviderName = "Galaxy",
|
|
};
|
|
}
|
|
}
|