Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs
Joseph Doherty aa8834a231 Phase 3 PR 40 — LiveStackSmokeTests: write-roundtrip + subscribe-receives-OnDataChange against the live Galaxy. Finishes LMX #5 by exercising the IWritable + ISubscribable capability paths end-to-end through the Proxy → OtOpcUaGalaxyHost service → MXAccess → real Galaxy.
Two new facts target DelmiaReceiver_001.TestAttribute — the writable Boolean UDA on the TestMachine_001 hierarchy in this dev Galaxy. The user nominated TestMachine_001 (the deployed test-target object) as a scratch surface for live testing; ZB query showed DelmiaReceiver_001 carries one dynamic_attribute named TestAttribute (mx_data_type=1=Boolean, lock_type=0=writable, security_classification=1=Operate). Naming makes the intent obvious — the attribute exists for exactly this kind of integration testing — and Boolean keeps the assertions simple (invert, write, read back).
Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001: reads the current value as the baseline (Galaxy may return Uncertain quality until the Engine has scanned the attribute at least once — we don't read into a typed bool until Status is Good), inverts it, writes via IWritable, then polls reads in a 5s loop until either the new value comes back or the budget expires. The scan-window poll (rather than a single read after a fixed delay) accommodates Galaxy's variable scan latency on a fresh service start. Restore-on-finally writes the original value back so re-running the test doesn't accumulate a flipped TestAttribute on the dev box (Galaxy holds UDA values across runs since they're deployed). Best-effort restore — swallows exceptions so a failure in restore doesn't mask the primary assertion.
Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write: subscribes to the same attribute with a 250ms publishing interval, captures every OnDataChange notification onto a thread-safe ConcurrentQueue (MXAccess advisory fires on its own thread per Galaxy's COM apartment model — must not block it), waits up to 5s for the initial-value callback (per ISubscribable's contract: 'driver MAY fire OnDataChange immediately with the current value'), records the queue depth as a baseline, writes the toggled value, waits up to 8s for at least one MORE notification, then searches the queue tail for the notification carrying the toggled value (initial value may appear multiple times before the write commits — looking at the tail finds the post-write delta even if the queue grew during the wait window). Unsubscribes on finally + restores baseline.
Both tests use Convert.ToBoolean(value ?? false) to defensively handle the Boxed-vs-typed quirk in MessagePack-deserialized Galaxy values — depending on the wire encoding the Boolean might come back as System.Boolean or System.Object boxing one. Convert.ToBoolean handles both. Same pattern in OnReadValue's existing usage.
WaitForAsync helper does the loop+budget pattern shared by both tests.
PR 40 is the code side of LMX #5's final two deferred facts. To actually run them green requires re-executing from a normal (non-admin) PowerShell — the elevated-shell skip from PR 39 fires correctly under bash + sc.exe-context (verified). lmx-followups.md #5 updated to note the new facts + the run command + the one remaining genuine follow-up (alarm-condition fact when an alarm-flagged attribute is deployed on TestMachine_001).
Test posture from elevated bash: 7 LiveStackSmokeTests facts discovered (was 5; +2 new), all skip cleanly with the elevation message. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:38:34 -04:00

283 lines
14 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
/// <summary>
/// End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
/// Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
/// in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
/// live MXAccess runtime → real Galaxy objects + attributes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
/// <c>Assert.Skip</c> when missing):
/// </para>
/// <list type="bullet">
/// <item>AVEVA System Platform installed + Platform deployed.</item>
/// <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
/// <item>MXAccess COM server registered.</item>
/// <item>ZB database exists with at least one deployed gobject.</item>
/// <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
/// <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
/// service's registry Environment values (test host typically needs to be elevated
/// to read the latter).</item>
/// <item>Test process runs as the account listed in the service's pipe ACL
/// (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
/// </list>
/// <para>
/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a
/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't
/// accidentally mutate a process-critical value. Adding a write test is a follow-up
/// PR that reuses this fixture.
/// </para>
/// </remarks>
[Trait("Category", "LiveGalaxy")]
[Collection(LiveStackCollection.Name)]
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
{
[Fact]
public void Fixture_initialized_successfully()
{
fixture.SkipIfUnavailable();
// If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
// This is the cheapest possible assertion that the IPC handshake worked end-to-end;
// every other test in this class depends on it.
fixture.Driver.ShouldNotBeNull();
fixture.Config.ShouldNotBeNull();
fixture.PrerequisiteReport.ShouldNotBeNull();
fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
}
[Fact]
public void Driver_reports_Healthy_after_IPC_handshake()
{
fixture.SkipIfUnavailable();
var health = fixture.Driver!.GetHealth();
health.State.ShouldBe(DriverState.Healthy,
$"Expected Healthy after successful IPC connect; Reason={health.LastError}");
}
[Fact]
public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
{
fixture.SkipIfUnavailable();
var builder = new CapturingAddressSpaceBuilder();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
builder.Variables.Count.ShouldBeGreaterThan(0,
"Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
"Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");
// Every discovered attribute must carry a non-empty FullName so the OPC UA server can
// route reads/writes back. Regression guard — PR 19 normalized this across drivers.
builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
}
[Fact]
public void GetHostStatuses_reports_at_least_one_platform()
{
fixture.SkipIfUnavailable();
var statuses = fixture.Driver!.GetHostStatuses();
statuses.Count.ShouldBeGreaterThan(0,
"Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
"Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");
// Host names are driver-opaque to the Core but non-empty by contract.
statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
}
[Fact]
public async Task Can_read_a_discovered_variable_from_live_galaxy()
{
fixture.SkipIfUnavailable();
var builder = new CapturingAddressSpaceBuilder();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
builder.Variables.Count.ShouldBeGreaterThan(0);
// Pick the first discovered variable. Read-only smoke — we don't assert on Value,
// only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
// returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
// Uncertain quality until the Engine's first scan publishes them, which is fine here.
var full = builder.Variables[0].AttributeInfo.FullName;
var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);
snapshots.Count.ShouldBe(1);
var snap = snapshots[0];
snap.StatusCode.ShouldNotBe(0x80020000u,
$"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
}
[Fact]
public async Task Write_then_read_roundtrips_a_writable_Boolean_attribute_on_TestMachine_001()
{
// PR 40 — finishes LMX #5. Targets DelmiaReceiver_001.TestAttribute, the writable
// Boolean attribute on the TestMachine_001 hierarchy that the dev Galaxy was deployed
// with for exactly this kind of integration testing. We invert the current value and
// assert the new value comes back, then restore the original so the test is effectively
// idempotent (Galaxy holds the value across runs since it's a deployed UDA).
fixture.SkipIfUnavailable();
const string fullRef = "DelmiaReceiver_001.TestAttribute";
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
// Read current value first — gives the cleanup path the right baseline. Galaxy may
// return Uncertain quality until the Engine has scanned the attribute at least once;
// we don't read into a strongly-typed bool until Status is Good.
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
before.StatusCode.ShouldNotBe(0x80020000u, $"baseline read failed for {fullRef}: {before.Value}");
var originalBool = Convert.ToBoolean(before.Value ?? false);
var inverted = !originalBool;
try
{
// Write the inverted value via IWritable.
var writeResults = await fixture.Driver!.WriteAsync(
[new(fullRef, inverted)], cts.Token);
writeResults.Count.ShouldBe(1);
writeResults[0].StatusCode.ShouldBe(0u,
$"WriteAsync returned status 0x{writeResults[0].StatusCode:X8} for {fullRef} — " +
$"check the Host service log at %ProgramData%\\OtOpcUa\\Galaxy\\.");
// The Engine's scan + acknowledgement is async — read in a short loop with a 5s
// budget. Galaxy's attribute roundtrip on a dev box is typically sub-second but
// we give headroom for first-scan after a service restart.
DataValueSnapshot after = default!;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
after = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
if (after.StatusCode == 0u && Convert.ToBoolean(after.Value ?? false) == inverted) break;
await Task.Delay(200, cts.Token);
}
after.StatusCode.ShouldBe(0u, "post-write read failed");
Convert.ToBoolean(after.Value ?? false).ShouldBe(inverted,
$"Wrote {inverted} but Galaxy returned {after.Value} after the scan window.");
}
finally
{
// Restore — best-effort. If this throws the test still reports its primary result;
// we just leave a flipped TestAttribute on the dev box (benign, name says it all).
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); }
catch { /* swallow */ }
}
}
[Fact]
public async Task Subscribe_fires_OnDataChange_with_initial_value_then_again_after_a_write()
{
// Subscribe + write is the canonical "is the data path actually live" test for
// an OPC UA driver. We subscribe to the same Boolean attribute, expect an initial-
// value callback within a couple of seconds (per ISubscribable's contract — the
// driver MAY fire OnDataChange immediately with the current value), then write a
// distinct value and expect a second callback carrying the new value.
fixture.SkipIfUnavailable();
const string fullRef = "DelmiaReceiver_001.TestAttribute";
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
// Capture every OnDataChange notification for this fullRef onto a thread-safe queue
// we can poll from the test thread. Galaxy's MXAccess advisory fires on its own
// thread; we don't want to block it.
var notifications = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
void Handler(object? sender, DataChangeEventArgs e)
{
if (string.Equals(e.FullReference, fullRef, StringComparison.OrdinalIgnoreCase))
notifications.Enqueue(e.Snapshot);
}
fixture.Driver!.OnDataChange += Handler;
// Read current value so we know which value to write to force a transition.
var before = (await fixture.Driver!.ReadAsync([fullRef], cts.Token))[0];
var originalBool = Convert.ToBoolean(before.Value ?? false);
var toWrite = !originalBool;
ISubscriptionHandle? handle = null;
try
{
handle = await fixture.Driver!.SubscribeAsync(
[fullRef], TimeSpan.FromMilliseconds(250), cts.Token);
// Wait for initial-value notification — typical < 1s on a hot Galaxy, give 5s.
await WaitForAsync(() => notifications.Count >= 1, TimeSpan.FromSeconds(5), cts.Token);
notifications.Count.ShouldBeGreaterThanOrEqualTo(1,
$"No initial-value OnDataChange for {fullRef} within 5s. " +
$"Either MXAccess subscription failed silently or the Engine hasn't scanned yet.");
// Drain the initial-value queue before writing so we count post-write deltas only.
var initialCount = notifications.Count;
// Write the toggled value. Engine scan + advisory fires the second callback.
var w = await fixture.Driver!.WriteAsync([new(fullRef, toWrite)], cts.Token);
w[0].StatusCode.ShouldBe(0u);
await WaitForAsync(() => notifications.Count > initialCount, TimeSpan.FromSeconds(8), cts.Token);
notifications.Count.ShouldBeGreaterThan(initialCount,
$"OnDataChange did not fire after writing {toWrite} to {fullRef} within 8s.");
// Find the post-write notification carrying the toggled value (initial value may
// appear multiple times before the write commits — search the tail).
var postWrite = notifications.ToArray().Reverse()
.FirstOrDefault(n => n.StatusCode == 0u && Convert.ToBoolean(n.Value ?? false) == toWrite);
postWrite.ShouldNotBe(default,
$"No OnDataChange carrying the toggled value {toWrite} appeared in the queue: " +
string.Join(",", notifications.Select(n => $"{n.Value}@{n.StatusCode:X8}")));
}
finally
{
fixture.Driver!.OnDataChange -= Handler;
if (handle is not null)
{
try { await fixture.Driver!.UnsubscribeAsync(handle, cts.Token); } catch { /* swallow */ }
}
// Restore baseline.
try { await fixture.Driver!.WriteAsync([new(fullRef, originalBool)], cts.Token); } catch { /* swallow */ }
}
}
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan budget, CancellationToken ct)
{
var deadline = DateTime.UtcNow + budget;
while (DateTime.UtcNow < deadline)
{
if (predicate()) return;
await Task.Delay(100, ct);
}
}
/// <summary>
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
/// Variable() call into a flat list so tests can inspect what discovery produced
/// without running the full OPC UA node-manager stack.
/// </summary>
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new NoopHandle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class NoopHandle(string fullReference) : IVariableHandle
{
public string FullReference { get; } = fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
private sealed class NoopSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
}