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. UnackAlm→AckAlm
/// (Acknowledge) from Unspecified→UnackAlm (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",
};
}
}