diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md index 21b3c99..1c36b1a 100644 --- a/docs/v2/lmx-followups.md +++ b/docs/v2/lmx-followups.md @@ -125,14 +125,29 @@ Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` / `OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's registry-stored Environment values (requires elevated test host). -**Remaining**: -- Install + run the `OtOpcUaGalaxyHost` + `OtOpcUa` services on the dev box - (`scripts/install/Install-Services.ps1`) so the skip-on-unready tests - actually execute and the smoke PR lands green. -- Subscribe-and-receive-data-change fact (needs a known tag that actually - ticks; deferred until operators confirm a scratch tag exists). -- Write-and-roundtrip fact (needs a test-only UDA or agreed scratch tag - so we can't accidentally mutate a process-critical value). +**PR 40** added the write + subscribe facts targeting +`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy +ships under TestMachine_001) — write-then-read with a 5s scan-window poll + +restore-on-finally, and subscribe-then-write asserting both an initial-value +OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell +short-circuit so a developer running from an admin window gets an actionable +skip instead of `UnauthorizedAccessException`. + +**Run the live tests** (from a NORMAL non-admin PowerShell): + +```powershell +$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt +cd C:\Users\dohertj2\Desktop\lmxopcua +dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests" +``` + +Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service. + +**Remaining for #5 in production-grade form**: +- Confirm the suite passes from a non-elevated shell (operator action). +- Add similar facts for an alarm-source attribute once `TestMachine_001` (or + a sibling) carries a deployed alarm condition — the current dev Galaxy's + TestAttribute isn't alarm-flagged. ## 6. Second driver instance on the same server — **DONE (PR 32)** diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs index 18fd429..b2381b2 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/LiveStack/LiveStackSmokeTests.cs @@ -117,6 +117,141 @@ public sealed class LiveStackSmokeTests(LiveStackFixture fixture) $"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