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; /// /// Unit-test coverage for 's pure /// parsing helpers — XML payload → /// dictionary, and the 32-char-hex GUID round-trip. The COM-side /// polling loop is verified separately by the Skip-gated /// WnWrapConsumerProbeTests on a live AVEVA install. /// public sealed class WnWrapAlarmConsumerXmlTests { /// Captured XML from the dev rig (probe run 2026-05-01). private const string SingleAlarmActiveXml = "" + "BCC4705395424D65BDAABCDEA6A32A73" + "2026/5/1" + "2400" + "DESKTOP-6JL3KKO" + "Galaxy" + "TestArea" + "TestMachine_001.TestAlarm001" + "DSCtruetrue" + "500UNACK_ALM" + "" + "Test alarm #1" + ""; private const string EmptyXml = ""; /// Verifies that empty XML payload returns an empty dictionary. [Fact] public void ParseSnapshotXml_WithEmptyPayload_ReturnsEmptyDictionary() { var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml); Assert.Empty(records); } /// Verifies that null or whitespace payload returns an empty dictionary. [Fact] public void ParseSnapshotXml_WithNullOrWhitespace_ReturnsEmptyDictionary() { Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml("")); Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" ")); } /// Verifies that single alarm XML payload decodes the record correctly. [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); } /// Verifies that invalid GUIDs in XML payload are silently dropped. [Fact] public void ParseSnapshotXml_WithInvalidGuids_SilentlyDropsRecords() { string xml = SingleAlarmActiveXml.Replace( "BCC4705395424D65BDAABCDEA6A32A73", "not-a-guid"); Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml)); } /// Verifies that dashless 32-character hex GUIDs parse correctly. /// The dashless hex string. /// The expected canonical GUID form. [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); } /// Verifies that canonical dashed GUID format is accepted. /// The canonical GUID form. [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); } /// Verifies that invalid GUID inputs are rejected. /// The invalid GUID hex string. [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); } /// /// The consumer must own no internal /// . 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. /// [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)); } /// /// 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. /// [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); } } /// /// Pins the "new alarm sighting" branch of /// . A GUID /// that appears in next but not in previous must /// produce exactly one transition with /// as the previous /// state — the proto layer relies on this sentinel to map a /// first sighting to a Raise. /// [Fact] public void ComputeTransitions_WhenAlarmIsNewInNextSnapshot_EmitsTransitionWithUnspecifiedPreviousState() { Guid alarmGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73"); Dictionary previous = new(); Dictionary next = new() { [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), }; IReadOnlyList 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); } /// /// Pins the "state unchanged" branch. A GUID /// present in both snapshots with identical /// must produce no /// transition — a regression that emits a transition every poll /// regardless of state change would slip through without this /// test. /// [Fact] public void ComputeTransitions_WhenAlarmStateUnchanged_EmitsNoTransition() { Guid alarmGuid = Guid.NewGuid(); Dictionary previous = new() { [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), }; Dictionary next = new() { [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), }; IReadOnlyList transitions = WnWrapAlarmConsumer.ComputeTransitions(previous, next); Assert.Empty(transitions); } /// /// 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. UnackAlmAckAlm /// (Acknowledge) from UnspecifiedUnackAlm (Raise). /// [Fact] public void ComputeTransitions_WhenAlarmStateChanged_EmitsTransitionWithPriorState() { Guid alarmGuid = Guid.NewGuid(); Dictionary previous = new() { [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), }; Dictionary next = new() { [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.AckAlm), }; IReadOnlyList 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); } /// /// Pins the "alarm cleared from the active set" /// branch. AVEVA drops cleared alarms from /// GetXmlCurrentAlarms2's active set rather than emitting a /// transition record. A GUID present in /// previous but absent from next 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. /// [Fact] public void ComputeTransitions_WhenAlarmDroppedFromActiveSet_EmitsNoTransition() { Guid alarmGuid = Guid.NewGuid(); Dictionary previous = new() { [alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm), }; Dictionary next = new(); IReadOnlyList transitions = WnWrapAlarmConsumer.ComputeTransitions(previous, next); Assert.Empty(transitions); } /// /// 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. /// [Fact] public void ComputeTransitions_WithMixedDelta_EmitsOnlyNewAndChangedTransitions() { Guid newGuid = Guid.NewGuid(); Guid changedGuid = Guid.NewGuid(); Guid unchangedGuid = Guid.NewGuid(); Guid droppedGuid = Guid.NewGuid(); Dictionary previous = new() { [changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.UnackAlm), [unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm), [droppedGuid] = NewRecord(droppedGuid, MxAlarmStateKind.UnackAlm), }; Dictionary next = new() { [newGuid] = NewRecord(newGuid, MxAlarmStateKind.UnackAlm), [changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.AckAlm), [unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm), }; IReadOnlyList 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. // ------------------------------------------------------------------------- /// /// A fetch that comes back holding exactly the cap must be treated as /// truncated; anything below the cap must not. /// /// Records the reply carried. /// The cap passed to the fetch. /// Whether the reply must read as truncated. [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)); } /// /// 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 /// 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. /// [Fact] public void ApplySnapshotUpdate_WhenFetchTruncated_RetainsAlarmMissingFromCappedFetch() { const int Cap = 1024; Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111"); string xml = BuildAlarmXml(Cap); Dictionary 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 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 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); } /// /// 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. /// [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 next = WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount); Assert.Equal(Cap - 1, fetchedRecordCount); Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); Dictionary 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); } /// /// 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. /// [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 snapshot = new() { [presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.UnackAlm), [missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm), }; Dictionary next = new() { [presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.AckAlm), }; IReadOnlyList 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)); } /// /// 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. /// [Fact] public void ParseSnapshotXml_CountsRawAlarmElements_IncludingRecordsDroppedForBadGuid() { const int Cap = 64; string xml = BuildAlarmXml(Cap).Replace( "00000000000000000000000000000001", "not-a-guid"); Dictionary records = WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount); Assert.Equal(Cap, fetchedRecordCount); Assert.Equal(Cap - 1, records.Count); Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); } /// /// 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. /// /// Raw environment value under test. /// Expected resolved cap. [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); } } /// /// 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. /// [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)); } /// /// Builds a well-formed ALARM_RECORDS payload with /// 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. /// /// Number of ALARM elements to emit. /// The XML payload. private static string BuildAlarmXml(int count) { System.Text.StringBuilder sb = new System.Text.StringBuilder(); sb.Append(""); for (int index = 1; index <= count; index++) { sb.Append("") .Append(index.ToString("X32", System.Globalization.CultureInfo.InvariantCulture)) .Append("") .Append("2026/5/1") .Append("2400") .Append("TEST-NODE") .Append("Galaxy") .Append("TestArea") .Append("TestMachine_") .Append(index.ToString(System.Globalization.CultureInfo.InvariantCulture)) .Append(".TestAlarm") .Append("DSCtruetrue") .Append("500UNACK_ALM") .Append("") .Append("Test alarm"); } sb.Append(""); 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. // ------------------------------------------------------------------------- /// /// An absent child element and a present-but-empty one must both yield /// , exactly as /// SelectSingleNode(name)?.InnerText ?? string.Empty did. /// [Fact] public void ParseSnapshotXml_WithAbsentAndEmptyChildren_YieldsEmptyStrings() { string xml = "" + "BCC4705395424D65BDAABCDEA6A32A73" + "" + "UNACK_ALM" + ""; Dictionary 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); } /// /// Duplicate child elements must resolve to the FIRST occurrence — /// SelectSingleNode returned the first match, so a last-wins /// walk would silently change which value a malformed payload yields. /// [Fact] public void ParseSnapshotXml_WithDuplicateChildElements_TakesFirstOccurrence() { string xml = "" + "BCC4705395424D65BDAABCDEA6A32A73" + "FirstSecond" + "100900" + "UNACK_ALM" + ""; Dictionary records = WnWrapAlarmConsumer.ParseSnapshotXml(xml); MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]; Assert.Equal("First", record.TagName); Assert.Equal(100, record.Priority); } /// /// 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. /// [Fact] public void ParseSnapshotXml_WithWrongCaseChildElement_DoesNotPopulateField() { string xml = "" + "BCC4705395424D65BDAABCDEA6A32A73" + "ShouldBeIgnored" + "UNACK_ALM" + ""; Dictionary records = WnWrapAlarmConsumer.ParseSnapshotXml(xml); MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]; Assert.Equal(string.Empty, record.TagName); } /// /// 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 InnerText always did. /// [Fact] public void ParseSnapshotXml_WithCommentsAndWhitespace_IgnoresNonElementChildren() { string xml = "\n" + " \n" + " \n" + " BCC4705395424D65BDAABCDEA6A32A73\n" + " TestMachine.TestAlarm\n" + " UNACK_ALM\n" + " \n" + ""; Dictionary 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", }; } }