Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/EndToEndIpcTests.cs
Joseph Doherty 32eeeb9e04 Phase 2 Streams A+B+C feature-complete — real Win32 pump, all 9 IDriver capabilities, end-to-end IPC dispatch. Streams D+E remain (Galaxy MXAccess code lift + parity-debug cycle, plan-budgeted 3-4 weeks). The 494 v1 IntegrationTests still pass — legacy OtOpcUa.Host untouched. StaPump replaces the BlockingCollection placeholder with a real Win32 message pump lifted from v1 StaComThread per CLAUDE.md "Reference Implementation": dedicated STA Thread with SetApartmentState(STA), GetMessage/PostThreadMessage/PeekMessage/TranslateMessage/DispatchMessage/PostQuitMessage P/Invoke, WM_APP=0x8000 for work-item dispatch, WM_APP+1 for graceful-drain → PostQuitMessage, peek-pm-noremove on entry to force the system to create the thread message queue before signalling Started, IsResponsiveAsync probe still no-op-round-trips through PostThreadMessage so the wedge detection works against the real pump. Concurrent ConcurrentQueue<WorkItem> drains on every WM_APP; fault path on dispose drains-and-faults all pending work-item TCSes with InvalidOperationException("STA pump has exited"). All three StaPumpTests pass against the real pump (apartment state STA, healthy probe true, wedged probe false). GalaxyProxyDriver now implements every Phase 2 Stream C capability — IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe — each forwarding through the matching IPC contract. ReadAsync preserves request order even when the Host returns out-of-order values; WriteAsync MessagePack-serializes the value into ValueBytes; SubscribeAsync wraps SubscriptionId in a GalaxySubscriptionHandle record; UnsubscribeAsync uses the new SendOneWayAsync helper on GalaxyIpcClient (fire-and-forget but still gated through the call-semaphore so it doesn't interleave with CallAsync); AlarmSubscribe is one-way and the Host pushes events back via OnAlarmEvent; ReadProcessedAsync short-circuits to NotSupportedException (Galaxy historian only does raw); IRediscoverable's OnRediscoveryNeeded fires when the Host pushes a deploy-watermark notification; IHostConnectivityProbe.GetHostStatuses() snapshots and OnHostStatusChanged fires on Running↔Stopped/Faulted transitions, with IpcHostConnectivityStatus aliased to disambiguate from the Core.Abstractions namespace's same-named type. Internal RaiseDataChange/RaiseAlarmEvent/RaiseRediscoveryNeeded/OnHostConnectivityUpdate methods are the entry points the IPC client will invoke when push frames arrive. Host side: new Backend/IGalaxyBackend interface defines the seam between IPC dispatch and the live MXAccess code (so the dispatcher is unit-testable against an in-memory mock without needing live Galaxy); Backend/StubGalaxyBackend returns success for OpenSession/CloseSession/Subscribe/Unsubscribe/AlarmSubscribe/AlarmAck/Recycle and a recognizable "stub: MXAccess code lift pending (Phase 2 Task B.1)"-tagged error for Discover/ReadValues/WriteValues/HistoryRead — keeps the IPC end-to-end testable today and gives the parity team a clear seam to slot the real implementation into; Ipc/GalaxyFrameHandler is the new real dispatcher (replaces StubFrameHandler in Program.cs) — switch on MessageKind, deserialize the matching contract, await backend method, write the response (one-way for Unsubscribe/AlarmSubscribe/AlarmAck/CloseSession), heartbeat handled inline so liveness still works if the backend is sick, exceptions caught and surfaced as ErrorResponse with code "handler-exception" so the Proxy raises GalaxyIpcException instead of disconnecting. End-to-end IPC integration test (EndToEndIpcTests) drives every operation through the full stack — Initialize → Read → Write → Subscribe → Unsubscribe → SubscribeAlarms → AlarmAck → ReadRaw → ReadProcessed (short-circuit) — proving the wire protocol, dispatcher, capability forwarding, and one-way semantics agree end-to-end. Skipped on Windows administrator shells per the same PipeAcl-denies-Administrators reasoning the IpcHandshakeIntegrationTests use. Full solution 952 pass / 1 pre-existing Phase 0 baseline. Phase 2 evidence doc updated: status header now reads "Streams A+B+C complete... Streams D+E remain — gated only on the iterative Galaxy code lift + parity-debug cycle"; new Update 2026-04-17 (later) callout enumerates the upgrade with explicit "what's left for the Phase 2 exit gate" — replace StubGalaxyBackend with a MxAccessClient-backed implementation calling on the StaPump, then run the v1 IntegrationTests against the v2 topology and iterate on parity defects until green, then delete legacy OtOpcUa.Host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:02:00 -04:00

192 lines
7.1 KiB
C#

using System.Security.Principal;
using Serilog;
using Serilog.Core;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
/// <summary>
/// Drives every <see cref="MessageKind"/> through the full IPC stack — Host
/// <see cref="GalaxyFrameHandler"/> backed by <see cref="StubGalaxyBackend"/> on one end,
/// <see cref="GalaxyProxyDriver"/> on the other — to prove the wire protocol, dispatcher,
/// and capability forwarding agree end-to-end. The "stub backend" replies with success for
/// lifecycle/subscribe/recycle and a recognizable "not-implemented" error for the data-plane
/// calls that need the deferred MXAccess lift; the test asserts both shapes.
/// </summary>
[Trait("Category", "Integration")]
public sealed class EndToEndIpcTests
{
private static bool IsAdministrator()
{
if (!OperatingSystem.IsWindows()) return false;
using var identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
private static (string Pipe, string Secret, SecurityIdentifier Sid) MakeIpcParams() =>
($"OtOpcUaGalaxyE2E-{Guid.NewGuid():N}",
"e2e-secret",
WindowsIdentity.GetCurrent().User!);
private static async Task<(GalaxyProxyDriver Driver, CancellationTokenSource Cts, Task ServerTask, PipeServer Server)>
StartStackAsync()
{
var (pipe, secret, sid) = MakeIpcParams();
Logger log = new LoggerConfiguration().CreateLogger();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var server = new PipeServer(pipe, sid, secret, log);
var backend = new StubGalaxyBackend();
var handler = new GalaxyFrameHandler(backend, log);
var serverTask = Task.Run(() => server.RunAsync(handler, cts.Token));
var driver = new GalaxyProxyDriver(new GalaxyProxyOptions
{
DriverInstanceId = "gal-e2e",
PipeName = pipe,
SharedSecret = secret,
ConnectTimeout = TimeSpan.FromSeconds(5),
});
await driver.InitializeAsync(driverConfigJson: "{}", cts.Token);
return (driver, cts, serverTask, server);
}
[Fact]
public async Task Initialize_succeeds_via_OpenSession_and_health_goes_Healthy()
{
if (!OperatingSystem.IsWindows() || IsAdministrator()) return;
var (driver, cts, serverTask, server) = await StartStackAsync();
try
{
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
cts.Cancel();
try { await serverTask; } catch { /* shutdown */ }
server.Dispose();
driver.Dispose();
}
}
[Fact]
public async Task Read_returns_Bad_status_for_each_requested_reference_until_backend_lifted()
{
if (!OperatingSystem.IsWindows() || IsAdministrator()) return;
var (driver, cts, serverTask, server) = await StartStackAsync();
try
{
// Stub backend currently fails the whole batch with a "not-implemented" error;
// the driver surfaces this as InvalidOperationException with the error text.
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
driver.ReadAsync(["TagA", "TagB"], cts.Token));
ex.Message.ShouldContain("MXAccess code lift pending");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
driver.Dispose();
}
}
[Fact]
public async Task Write_returns_per_tag_BadInternalError_status_until_backend_lifted()
{
if (!OperatingSystem.IsWindows() || IsAdministrator()) return;
var (driver, cts, serverTask, server) = await StartStackAsync();
try
{
// Stub backend's WriteValuesAsync returns a per-tag bad status — the proxy
// surfaces those without throwing.
var results = await driver.WriteAsync([new WriteRequest("TagA", 42)], cts.Token);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(0x80020000u); // Bad_InternalError
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
driver.Dispose();
}
}
[Fact]
public async Task Subscribe_returns_handle_then_Unsubscribe_closes_cleanly()
{
if (!OperatingSystem.IsWindows() || IsAdministrator()) return;
var (driver, cts, serverTask, server) = await StartStackAsync();
try
{
var handle = await driver.SubscribeAsync(
["TagA"], TimeSpan.FromMilliseconds(500), cts.Token);
handle.DiagnosticId.ShouldStartWith("galaxy-sub-");
await driver.UnsubscribeAsync(handle, cts.Token); // one-way; just verify no throw
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
driver.Dispose();
}
}
[Fact]
public async Task SubscribeAlarms_and_Acknowledge_round_trip_without_errors()
{
if (!OperatingSystem.IsWindows() || IsAdministrator()) return;
var (driver, cts, serverTask, server) = await StartStackAsync();
try
{
var handle = await driver.SubscribeAlarmsAsync(["Eq001"], cts.Token);
handle.DiagnosticId.ShouldNotBeNullOrEmpty();
await driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("Eq001", "evt-1", "test ack")],
cts.Token);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
driver.Dispose();
}
}
[Fact]
public async Task ReadProcessed_throws_NotSupported_immediately_without_round_trip()
{
// No IPC needed — the proxy short-circuits to NotSupportedException per the v2 design
// (Galaxy Historian only supports raw reads; processed reads are an OPC UA aggregate
// computed by the OPC UA stack, not the driver).
var driver = new GalaxyProxyDriver(new GalaxyProxyOptions
{
DriverInstanceId = "gal-stub", PipeName = "x", SharedSecret = "x",
});
await Should.ThrowAsync<NotSupportedException>(() =>
driver.ReadProcessedAsync("TagA", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
TimeSpan.FromMinutes(1), HistoryAggregateType.Average, CancellationToken.None));
}
}