703 lines
30 KiB
C#
703 lines
30 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);
|
|
}
|
|
|
|
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
|
|
{
|
|
return new MxAlarmSnapshotRecord
|
|
{
|
|
AlarmGuid = guid,
|
|
State = state,
|
|
TagName = "TestMachine.TestAlarm",
|
|
ProviderNode = "TEST-NODE",
|
|
ProviderName = "Galaxy",
|
|
};
|
|
}
|
|
}
|