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