From aa8834a2316108e599579f93709f18d369250370 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 19:38:34 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2040=20=E2=80=94=20LiveStackSmok?= =?UTF-8?q?eTests:=20write-roundtrip=20+=20subscribe-receives-OnDataChange?= =?UTF-8?q?=20against=20the=20live=20Galaxy.=20Finishes=20LMX=20#5=20by=20?= =?UTF-8?q?exercising=20the=20IWritable=20+=20ISubscribable=20capability?= =?UTF-8?q?=20paths=20end-to-end=20through=20the=20Proxy=20=E2=86=92=20OtO?= =?UTF-8?q?pcUaGalaxyHost=20service=20=E2=86=92=20MXAccess=20=E2=86=92=20r?= =?UTF-8?q?eal=20Galaxy.=20Two=20new=20facts=20target=20DelmiaReceiver=5F0?= =?UTF-8?q?01.TestAttribute=20=E2=80=94=20the=20writable=20Boolean=20UDA?= =?UTF-8?q?=20on=20the=20TestMachine=5F001=20hierarchy=20in=20this=20dev?= =?UTF-8?q?=20Galaxy.=20The=20user=20nominated=20TestMachine=5F001=20(the?= =?UTF-8?q?=20deployed=20test-target=20object)=20as=20a=20scratch=20surfac?= =?UTF-8?q?e=20for=20live=20testing;=20ZB=20query=20showed=20DelmiaReceive?= =?UTF-8?q?r=5F001=20carries=20one=20dynamic=5Fattribute=20named=20TestAtt?= =?UTF-8?q?ribute=20(mx=5Fdata=5Ftype=3D1=3DBoolean,=20lock=5Ftype=3D0=3Dw?= =?UTF-8?q?ritable,=20security=5Fclassification=3D1=3DOperate).=20Naming?= =?UTF-8?q?=20makes=20the=20intent=20obvious=20=E2=80=94=20the=20attribute?= =?UTF-8?q?=20exists=20for=20exactly=20this=20kind=20of=20integration=20te?= =?UTF-8?q?sting=20=E2=80=94=20and=20Boolean=20keeps=20the=20assertions=20?= =?UTF-8?q?simple=20(invert,=20write,=20read=20back).=20Write=5Fthen=5Frea?= =?UTF-8?q?d=5Froundtrips=5Fa=5Fwritable=5FBoolean=5Fattribute=5Fon=5FTest?= =?UTF-8?q?Machine=5F001:=20reads=20the=20current=20value=20as=20the=20bas?= =?UTF-8?q?eline=20(Galaxy=20may=20return=20Uncertain=20quality=20until=20?= =?UTF-8?q?the=20Engine=20has=20scanned=20the=20attribute=20at=20least=20o?= =?UTF-8?q?nce=20=E2=80=94=20we=20don't=20read=20into=20a=20typed=20bool?= =?UTF-8?q?=20until=20Status=20is=20Good),=20inverts=20it,=20writes=20via?= =?UTF-8?q?=20IWritable,=20then=20polls=20reads=20in=20a=205s=20loop=20unt?= =?UTF-8?q?il=20either=20the=20new=20value=20comes=20back=20or=20the=20bud?= =?UTF-8?q?get=20expires.=20The=20scan-window=20poll=20(rather=20than=20a?= =?UTF-8?q?=20single=20read=20after=20a=20fixed=20delay)=20accommodates=20?= =?UTF-8?q?Galaxy's=20variable=20scan=20latency=20on=20a=20fresh=20service?= =?UTF-8?q?=20start.=20Restore-on-finally=20writes=20the=20original=20valu?= =?UTF-8?q?e=20back=20so=20re-running=20the=20test=20doesn't=20accumulate?= =?UTF-8?q?=20a=20flipped=20TestAttribute=20on=20the=20dev=20box=20(Galaxy?= =?UTF-8?q?=20holds=20UDA=20values=20across=20runs=20since=20they're=20dep?= =?UTF-8?q?loyed).=20Best-effort=20restore=20=E2=80=94=20swallows=20except?= =?UTF-8?q?ions=20so=20a=20failure=20in=20restore=20doesn't=20mask=20the?= =?UTF-8?q?=20primary=20assertion.=20Subscribe=5Ffires=5FOnDataChange=5Fwi?= =?UTF-8?q?th=5Finitial=5Fvalue=5Fthen=5Fagain=5Fafter=5Fa=5Fwrite:=20subs?= =?UTF-8?q?cribes=20to=20the=20same=20attribute=20with=20a=20250ms=20publi?= =?UTF-8?q?shing=20interval,=20captures=20every=20OnDataChange=20notificat?= =?UTF-8?q?ion=20onto=20a=20thread-safe=20ConcurrentQueue=20(MXAccess=20ad?= =?UTF-8?q?visory=20fires=20on=20its=20own=20thread=20per=20Galaxy's=20COM?= =?UTF-8?q?=20apartment=20model=20=E2=80=94=20must=20not=20block=20it),=20?= =?UTF-8?q?waits=20up=20to=205s=20for=20the=20initial-value=20callback=20(?= =?UTF-8?q?per=20ISubscribable's=20contract:=20'driver=20MAY=20fire=20OnDa?= =?UTF-8?q?taChange=20immediately=20with=20the=20current=20value'),=20reco?= =?UTF-8?q?rds=20the=20queue=20depth=20as=20a=20baseline,=20writes=20the?= =?UTF-8?q?=20toggled=20value,=20waits=20up=20to=208s=20for=20at=20least?= =?UTF-8?q?=20one=20MORE=20notification,=20then=20searches=20the=20queue?= =?UTF-8?q?=20tail=20for=20the=20notification=20carrying=20the=20toggled?= =?UTF-8?q?=20value=20(initial=20value=20may=20appear=20multiple=20times?= =?UTF-8?q?=20before=20the=20write=20commits=20=E2=80=94=20looking=20at=20?= =?UTF-8?q?the=20tail=20finds=20the=20post-write=20delta=20even=20if=20the?= =?UTF-8?q?=20queue=20grew=20during=20the=20wait=20window).=20Unsubscribes?= =?UTF-8?q?=20on=20finally=20+=20restores=20baseline.=20Both=20tests=20use?= =?UTF-8?q?=20Convert.ToBoolean(value=20=3F=3F=20false)=20to=20defensively?= =?UTF-8?q?=20handle=20the=20Boxed-vs-typed=20quirk=20in=20MessagePack-des?= =?UTF-8?q?erialized=20Galaxy=20values=20=E2=80=94=20depending=20on=20the?= =?UTF-8?q?=20wire=20encoding=20the=20Boolean=20might=20come=20back=20as?= =?UTF-8?q?=20System.Boolean=20or=20System.Object=20boxing=20one.=20Conver?= =?UTF-8?q?t.ToBoolean=20handles=20both.=20Same=20pattern=20in=20OnReadVal?= =?UTF-8?q?ue's=20existing=20usage.=20WaitForAsync=20helper=20does=20the?= =?UTF-8?q?=20loop+budget=20pattern=20shared=20by=20both=20tests.=20PR=204?= =?UTF-8?q?0=20is=20the=20code=20side=20of=20LMX=20#5's=20final=20two=20de?= =?UTF-8?q?ferred=20facts.=20To=20actually=20run=20them=20green=20requires?= =?UTF-8?q?=20re-executing=20from=20a=20normal=20(non-admin)=20PowerShell?= =?UTF-8?q?=20=E2=80=94=20the=20elevated-shell=20skip=20from=20PR=2039=20f?= =?UTF-8?q?ires=20correctly=20under=20bash=20+=20sc.exe-context=20(verifie?= =?UTF-8?q?d).=20lmx-followups.md=20#5=20updated=20to=20note=20the=20new?= =?UTF-8?q?=20facts=20+=20the=20run=20command=20+=20the=20one=20remaining?= =?UTF-8?q?=20genuine=20follow-up=20(alarm-condition=20fact=20when=20an=20?= =?UTF-8?q?alarm-flagged=20attribute=20is=20deployed=20on=20TestMachine=5F?= =?UTF-8?q?001).=20Test=20posture=20from=20elevated=20bash:=207=20LiveStac?= =?UTF-8?q?kSmokeTests=20facts=20discovered=20(was=205;=20+2=20new),=20all?= =?UTF-8?q?=20skip=20cleanly=20with=20the=20elevation=20message.=20Build?= =?UTF-8?q?=20clean.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/lmx-followups.md | 31 ++-- .../LiveStack/LiveStackSmokeTests.cs | 135 ++++++++++++++++++ 2 files changed, 158 insertions(+), 8 deletions(-) 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