Files
mxaccessgw/src/MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs
T
Joseph Doherty f711a55be4 A.2: replace AlarmClientConsumer with wnwrap-based polling consumer
Switch the worker's alarm-consumer surface from `aaAlarmManagedClient.AlarmClient`
to `WNWRAPCONSUMERLib.wwAlarmConsumerClass` (CLSID 7AB52E5F-…) hosted by
`wnwrapConsumer.dll`. The new path returns alarm records as a BSTR XML
payload via `GetXmlCurrentAlarms2`, bypassing the FILETIME→DateTime
auto-marshaling that crashed `GetHighPriAlarm` with
ArgumentOutOfRangeException on every poll. Live captured 60/60 polls
clean against `\DESKTOP-6JL3KKO\Galaxy!DEV` while a System Platform
script flipped TestMachine_001.TestAlarm001 every 10s; the GUID,
priority, state (UNACK_ALM ↔ UNACK_RTN), and ASCII-formatted timestamps
arrived end-to-end.

Implementation:
- `Interop.WNWRAPCONSUMERLib.dll` generated via tlbimp, checked in under
  `lib/` so dev boxes don't need the SDK to build.
- New `WnWrapAlarmConsumer` (replaces `AlarmClientConsumer`): owns a
  500ms polling timer, parses `GetXmlCurrentAlarms2` output, diffs the
  snapshot keyed by alarm GUID, and raises one
  `MxAlarmTransitionEvent` per state change. Includes the
  Initialize→Register-before-Subscribe ordering fix found during
  Discovery probe runs.
- New library-agnostic types `MxAlarmSnapshotRecord` /
  `MxAlarmStateKind` / `MxAlarmTransitionEvent` so the proto-build
  path is testable without an AVEVA install.
- `AlarmRecordTransitionMapper` retired the COM-coupled
  `MapTransitionKind(eAlmTransitions)`; new pure helpers
  `ParseStateKind`, `MapTransition(prev, curr)`, and
  `ParseTransitionTimestampUtc` cover XML decode + state-delta logic.
- `IMxAccessAlarmConsumer` event surface changed from
  `EventHandler<AlarmRecord>` to `EventHandler<MxAlarmTransitionEvent>`
  and `SnapshotActiveAlarms()` returns `MxAlarmSnapshotRecord` —
  decoupling the interface from any specific COM library.
- Worker csproj drops `aaAlarmManagedClient` / `IAlarmMgrDataProvider`
  refs; adds `Interop.WNWRAPCONSUMERLib`.

Tests:
- 36 new unit tests (state-string mapping, prev/current → proto kind
  decision table, timestamp UTC reassembly, XML payload parser, 32-char
  hex GUID round-trip) covering everything that doesn't touch the live
  COM surface — all passing.
- Skip-gated `WnWrapConsumerProbeTests.ProbeWnWrapConsumer` archives
  the live capture flow for regression / future probes.

Docs:
- `docs/AlarmClientDiscovery.md` "Option A — captured" section records
  sample XML payloads, the mangled `SetXmlAlarmQuery` round-trip
  (prefer `Subscribe` for filtering), the `GetStatistics`
  AccessViolationException quirk, and the worker-integration outline.

Pre-existing failure noted (separate):
`MxAccessInteropReference_ExistsOnlyInWorkerProject` was already
failing on HEAD — the test project still references `ArchestrA.MxAccess`
for the Skip-gated discovery probes. Not regressed by this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:44:15 -04:00

288 lines
12 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using WNWRAPCONSUMERLib;
using Xunit.Abstractions;
namespace MxGateway.Worker.Tests;
/// <summary>
/// Runtime probe — instantiate AVEVA's standalone wnwrapConsumer COM
/// class (CLSID 7AB52E5F-36B2-4A30-AE46-952A746F667C, registered at
/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll),
/// subscribe to the dev rig's `\\&lt;machine&gt;\Galaxy!DEV` provider, and
/// poll <c>GetXmlCurrentAlarms2</c> while a System Platform script flips
/// <c>TestMachine_001.TestAlarm001</c> every 10s. The XML payload bypasses
/// the FILETIME→DateTime auto-marshaling that crashes
/// <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>.
///
/// Skip-gated; flip Skip=null to run on the dev rig.
/// </summary>
public sealed class WnWrapConsumerProbeTests
{
private static readonly string MachineName = Environment.MachineName;
private static readonly string SubscriptionExpression =
$@"\\{MachineName}\Galaxy!DEV";
// XML query form — per WIN-911 / ArchestrA reference. NODE is the
// machine, PROVIDER is the literal "Galaxy", GROUP is the area.
private static readonly string XmlAlarmQuery =
"<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">" +
"<QUERY>" +
$"<NODE>{Environment.MachineName}</NODE>" +
"<PROVIDER>Galaxy</PROVIDER>" +
"<GROUP>DEV</GROUP>" +
"</QUERY>" +
"</QUERIES>";
private const int MaxAlarmsPerFetch = 100;
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(30);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
private readonly ITestOutputHelper output;
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
private readonly Stopwatch elapsed = Stopwatch.StartNew();
public WnWrapConsumerProbeTests(ITestOutputHelper output)
{
this.output = output;
}
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
public void ProbeWnWrapConsumer()
{
Exception? threadException = null;
var done = new ManualResetEventSlim(false);
var thread = new Thread(() =>
{
try { RunProbe(); }
catch (Exception ex) { threadException = ex; }
finally { done.Set(); }
});
thread.IsBackground = false;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
done.Wait();
thread.Join();
output.WriteLine($"Captured {log.Count} log line(s):");
while (log.TryDequeue(out string? line))
{
output.WriteLine(line);
}
if (threadException != null)
{
throw threadException;
}
}
private void RunProbe()
{
wwAlarmConsumerClass? client = null;
try
{
Log("Creating wwAlarmConsumerClass via CoCreateInstance...");
client = new wwAlarmConsumerClass();
Log($"Instantiated. RuntimeType={client.GetType().FullName}");
// Lifecycle: per AlarmClientDiscovery.md finding, InitializeConsumer
// MUST precede RegisterConsumer for the alarm provider to become
// visible. The wnwrap surface mirrors that requirement.
try
{
int init = client.InitializeConsumer("MxGatewayProbe.WnWrap");
Log($"InitializeConsumer -> {init}");
}
catch (Exception ex)
{
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
}
try
{
// hWnd=0 — XML pull-based; no message pump needed.
int reg = client.RegisterConsumer(
hWnd: 0,
szProductName: "MxGatewayProbe",
szApplicationName: "MxGatewayProbe.WnWrap",
szVersion: "1.0");
Log($"RegisterConsumer(hWnd=0) -> {reg}");
}
catch (Exception ex)
{
Log($"RegisterConsumer threw: {ex.GetType().Name}: {ex.Message}");
}
// Try both subscription mechanisms: classic Subscribe (canonical
// scope from prior aaAlarmManagedClient probe), and
// SetXmlAlarmQuery (the wnwrap-native filter format).
try
{
int sub = client.Subscribe(
szSubscription: SubscriptionExpression,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
Log($"Subscribe('{SubscriptionExpression}') -> {sub}");
}
catch (Exception ex)
{
Log($"Subscribe threw: {ex.GetType().Name}: {ex.Message}");
}
try
{
Log($"SetXmlAlarmQuery payload: {XmlAlarmQuery}");
client.SetXmlAlarmQuery(XmlAlarmQuery);
Log("SetXmlAlarmQuery -> ok");
}
catch (Exception ex)
{
Log($"SetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
}
// Echo the query back so we can confirm what the consumer is
// actually filtering on (provider may rewrite or reject some
// attributes silently).
try
{
object echo = string.Empty;
client.GetXmlAlarmQuery(out echo);
Log($"GetXmlAlarmQuery (round-trip) -> {Truncate(echo?.ToString() ?? "<null>", 600)}");
}
catch (Exception ex)
{
Log($"GetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
}
// Pump phase: poll GetXmlCurrentAlarms2 every PollInterval; log on
// every change in payload. Run for PumpDuration. The user's flip
// script writes TestMachine_001.TestAlarm001 every 10s; expect at
// least 2-3 transitions over a 30s window.
Log($"Polling GetXmlCurrentAlarms2 every {PollInterval.TotalMilliseconds:F0}ms for {PumpDuration.TotalSeconds:F0}s.");
DateTime deadline = DateTime.UtcNow + PumpDuration;
DateTime nextPoll = DateTime.UtcNow;
int pollCount = 0;
string lastV2 = string.Empty;
string lastV1 = string.Empty;
int v2Ok = 0, v2Throw = 0, v1Ok = 0, v1Throw = 0;
int statsOk = 0, statsThrow = 0;
string lastStats = string.Empty;
while (DateTime.UtcNow < deadline)
{
if (DateTime.UtcNow >= nextPoll)
{
pollCount++;
// V2 channel.
try
{
object xml2 = string.Empty;
client.GetXmlCurrentAlarms2(MaxAlarmsPerFetch, out xml2);
v2Ok++;
string s = xml2?.ToString() ?? "<null>";
if (s != lastV2)
{
Log($"GetXmlCurrentAlarms2 #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
lastV2 = s;
}
}
catch (Exception ex)
{
v2Throw++;
string es = $"{ex.GetType().Name}: {ex.Message}";
if (es != lastV2)
{
Log($"GetXmlCurrentAlarms2 #{pollCount} threw: {es}");
lastV2 = es;
}
}
// V1 channel — different vtable slot; either may be the
// populated one in this AVEVA build.
try
{
object xml1 = string.Empty;
client.GetXmlCurrentAlarms(MaxAlarmsPerFetch, out xml1);
v1Ok++;
string s = xml1?.ToString() ?? "<null>";
if (s != lastV1)
{
Log($"GetXmlCurrentAlarms #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
lastV1 = s;
}
}
catch (Exception ex)
{
v1Throw++;
string es = $"{ex.GetType().Name}: {ex.Message}";
if (es != lastV1)
{
Log($"GetXmlCurrentAlarms #{pollCount} threw: {es}");
lastV1 = es;
}
}
// Stats channel — heartbeat + active-count even if the XML
// calls are dry, this surfaces whether wnwrap sees any
// alarms in the subscribed scope at all.
try
{
int pct, total, active, newAlms, changes;
client.GetStatistics(
out pct, out total, out active, out newAlms, out changes,
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
statsOk++;
string statsSummary = $"pct={pct} total={total} active={active} new={newAlms} changes={changes}";
if (statsSummary != lastStats)
{
Log($"GetStatistics #{pollCount} (CHANGED): {statsSummary}");
lastStats = statsSummary;
}
}
catch (Exception ex)
{
statsThrow++;
Log($"GetStatistics #{pollCount} threw: {ex.GetType().Name}: {ex.Message}");
}
nextPoll = DateTime.UtcNow + PollInterval;
}
Thread.Sleep(20);
}
Log($"Pump done. Tally: v2 ok={v2Ok} threw={v2Throw}, v1 ok={v1Ok} threw={v1Throw}, stats ok={statsOk} threw={statsThrow}");
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
try { int uninit = client.UninitializeConsumer(); Log($"UninitializeConsumer -> {uninit}"); }
catch (Exception ex) { Log($"UninitializeConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
}
finally
{
if (client != null && Marshal.IsComObject(client))
{
try { Marshal.FinalReleaseComObject(client); } catch { /* swallow */ }
}
}
}
private void Log(string line)
{
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max) return s ?? string.Empty;
return s.Substring(0, max) + $"…[+{s.Length - max} chars]";
}
}