|
|
|
|
@@ -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<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
|
|
|
|
|
|