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; /// /// End-to-end smoke against the installed OtOpcUaGalaxyHost Windows service. /// Closes LMX follow-up #5 — exercises the full topology: /// in-process → named-pipe IPC → OtOpcUaGalaxyHost service → MxAccessGalaxyBackend → /// live MXAccess runtime → real Galaxy objects + attributes. /// /// /// /// Preconditions (all checked by , surfaced via /// Assert.Skip when missing): /// /// /// AVEVA System Platform installed + Platform deployed. /// aaBootstrap / aaGR / NmxSvc / MSSQLSERVER running. /// MXAccess COM server registered. /// ZB database exists with at least one deployed gobject. /// OtOpcUaGalaxyHost service installed + running (named pipe accepting connections). /// Shared secret discoverable via OTOPCUA_GALAXY_SECRET env var or the /// service's registry Environment values (test host typically needs to be elevated /// to read the latter). /// Test process runs as the account listed in the service's pipe ACL /// (OTOPCUA_ALLOWED_SID, typically the service account per decision #76). /// /// /// 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. /// /// [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(); 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 predicate, TimeSpan budget, CancellationToken ct) { var deadline = DateTime.UtcNow + budget; while (DateTime.UtcNow < deadline) { if (predicate()) return; await Task.Delay(100, ct); } } /// /// Minimal 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. /// 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) { } } } } }