Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*
Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.
Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.
Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Exhaustive coverage of the runtime host probe manager: state machine, sync diff,
|
||||
/// transport gating, unknown-resolution timeout, and transition callbacks.
|
||||
/// </summary>
|
||||
public class GalaxyRuntimeProbeManagerTests
|
||||
{
|
||||
// ---------- State transitions ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_WithMixedRuntimeHosts_AddsProbesAndEntriesInUnknown()
|
||||
{
|
||||
var (client, clock) = (new FakeMxAccessClient(), new Clock());
|
||||
var (stopSpy, runSpy) = (new List<int>(), new List<int>());
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy, clock);
|
||||
|
||||
await sut.SyncAsync(new[]
|
||||
{
|
||||
Platform(10, "DevPlatform"),
|
||||
Engine(20, "DevAppEngine"),
|
||||
UserObject(30, "TestMachine_001")
|
||||
});
|
||||
|
||||
sut.ActiveProbeCount.ShouldBe(2);
|
||||
var snap = sut.GetSnapshot();
|
||||
snap.Select(s => s.ObjectName).ShouldBe(new[] { "DevAppEngine", "DevPlatform" });
|
||||
snap.All(s => s.State == GalaxyRuntimeState.Unknown).ShouldBeTrue();
|
||||
snap.First(s => s.ObjectName == "DevPlatform").Kind.ShouldBe("$WinPlatform");
|
||||
snap.First(s => s.ObjectName == "DevAppEngine").Kind.ShouldBe("$AppEngine");
|
||||
stopSpy.ShouldBeEmpty();
|
||||
runSpy.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleProbeUpdate_FirstGoodCallback_TransitionsUnknownToRunning()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
var handled = sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
var entry = sut.GetSnapshot().Single();
|
||||
entry.State.ShouldBe(GalaxyRuntimeState.Running);
|
||||
entry.LastScanState.ShouldBe(true);
|
||||
entry.GoodUpdateCount.ShouldBe(1);
|
||||
entry.FailureCount.ShouldBe(0);
|
||||
entry.LastError.ShouldBeNull();
|
||||
// Unknown → Running is startup initialization, not a recovery — the onHostRunning
|
||||
// callback is reserved for Stopped → Running transitions so the node manager does
|
||||
// not wipe Bad status set by a concurrently-stopping sibling host on the same variable.
|
||||
runSpy.ShouldBeEmpty();
|
||||
stopSpy.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleProbeUpdate_ScanStateFalse_TransitionsRunningToStopped()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
stopSpy.Clear(); runSpy.Clear();
|
||||
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false));
|
||||
|
||||
var entry = sut.GetSnapshot().Single();
|
||||
entry.State.ShouldBe(GalaxyRuntimeState.Stopped);
|
||||
entry.LastScanState.ShouldBe(false);
|
||||
entry.FailureCount.ShouldBe(1);
|
||||
entry.LastError!.ShouldContain("OffScan");
|
||||
stopSpy.ShouldBe(new[] { 20 });
|
||||
runSpy.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleProbeUpdate_BadQualityCallback_TransitionsRunningToStopped()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Platform(10, "DevPlatform") });
|
||||
sut.HandleProbeUpdate("DevPlatform.ScanState", Vtq.Good(true));
|
||||
stopSpy.Clear();
|
||||
|
||||
sut.HandleProbeUpdate("DevPlatform.ScanState", Vtq.Bad(Quality.BadCommFailure));
|
||||
|
||||
var entry = sut.GetSnapshot().Single();
|
||||
entry.State.ShouldBe(GalaxyRuntimeState.Stopped);
|
||||
entry.LastError!.ShouldContain("bad quality");
|
||||
stopSpy.ShouldBe(new[] { 10 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleProbeUpdate_RecoveryAfterStopped_FiresRunningCallback()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false));
|
||||
runSpy.Clear(); stopSpy.Clear();
|
||||
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
runSpy.ShouldBe(new[] { 20 });
|
||||
stopSpy.ShouldBeEmpty();
|
||||
var entry = sut.GetSnapshot().Single();
|
||||
entry.State.ShouldBe(GalaxyRuntimeState.Running);
|
||||
entry.LastError.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleProbeUpdate_RepeatedRunning_DoesNotRefire()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
// Unknown → Running is silent; subsequent Running updates are idempotent.
|
||||
runSpy.ShouldBeEmpty();
|
||||
sut.GetSnapshot().Single().GoodUpdateCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleProbeUpdate_NonProbeAddress_ReturnsFalse()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
var handled = sut.HandleProbeUpdate("UnrelatedObject.Value", Vtq.Good(42));
|
||||
|
||||
handled.ShouldBeFalse();
|
||||
sut.GetSnapshot().Single().GoodUpdateCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------- Unknown-resolution timeout ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_UnknownBeyondTimeout_TransitionsToStopped()
|
||||
{
|
||||
var clock = new Clock { Now = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) };
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy, clock);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
// 16 seconds later — past the 15s timeout
|
||||
clock.Now = clock.Now.AddSeconds(16);
|
||||
sut.Tick();
|
||||
|
||||
var entry = sut.GetSnapshot().Single();
|
||||
entry.State.ShouldBe(GalaxyRuntimeState.Stopped);
|
||||
entry.LastError!.ShouldContain("unknown-resolution");
|
||||
stopSpy.ShouldBe(new[] { 20 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_UnknownWithinTimeout_DoesNotTransition()
|
||||
{
|
||||
var clock = new Clock { Now = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) };
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy, clock);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
clock.Now = clock.Now.AddSeconds(10);
|
||||
sut.Tick();
|
||||
|
||||
sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Unknown);
|
||||
stopSpy.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_RunningHostWithOldCallback_DoesNotTransition()
|
||||
{
|
||||
// Critical on-change-semantic test: a stably Running host may go minutes or hours
|
||||
// without a callback. Tick must NOT time it out on a starvation basis.
|
||||
var clock = new Clock { Now = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) };
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy, clock);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
clock.Now = clock.Now.AddHours(2); // 2 hours of silence
|
||||
sut.Tick();
|
||||
|
||||
sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Running);
|
||||
}
|
||||
|
||||
// ---------- Transport gating ----------
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshot_WhenTransportDisconnected_ForcesEveryEntryToUnknown()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[]
|
||||
{
|
||||
Platform(10, "DevPlatform"),
|
||||
Engine(20, "DevAppEngine")
|
||||
});
|
||||
sut.HandleProbeUpdate("DevPlatform.ScanState", Vtq.Good(true));
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false));
|
||||
|
||||
client.State = ConnectionState.Disconnected;
|
||||
|
||||
sut.GetSnapshot().All(s => s.State == GalaxyRuntimeState.Unknown).ShouldBeTrue();
|
||||
|
||||
// Underlying state is preserved — restore transport and snapshot reflects reality again.
|
||||
client.State = ConnectionState.Connected;
|
||||
var restored = sut.GetSnapshot();
|
||||
restored.First(s => s.ObjectName == "DevPlatform").State.ShouldBe(GalaxyRuntimeState.Running);
|
||||
restored.First(s => s.ObjectName == "DevAppEngine").State.ShouldBe(GalaxyRuntimeState.Stopped);
|
||||
}
|
||||
|
||||
// ---------- Sync diff ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_WithHostRemoved_UnadvisesProbeAndDropsEntry()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[]
|
||||
{
|
||||
Platform(10, "DevPlatform"),
|
||||
Engine(20, "DevAppEngine")
|
||||
});
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
await sut.SyncAsync(new[] { Platform(10, "DevPlatform") });
|
||||
|
||||
sut.ActiveProbeCount.ShouldBe(1);
|
||||
sut.GetSnapshot().Single().ObjectName.ShouldBe("DevPlatform");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_WithUnchangedHostSet_PreservesExistingState()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
runSpy.Clear();
|
||||
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Running);
|
||||
runSpy.ShouldBeEmpty(); // no re-fire on no-op resync
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_FiltersNonRuntimeCategories()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[]
|
||||
{
|
||||
Platform(10, "DevPlatform"),
|
||||
UserObject(30, "TestMachine_001"),
|
||||
AreaObject(40, "DEV"),
|
||||
Engine(20, "DevAppEngine"),
|
||||
UserObject(31, "TestMachine_002")
|
||||
});
|
||||
|
||||
sut.ActiveProbeCount.ShouldBe(2); // only the platform + the engine
|
||||
}
|
||||
|
||||
// ---------- Dispose ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_UnadvisesEveryActiveProbe()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[]
|
||||
{
|
||||
Platform(10, "DevPlatform"),
|
||||
Engine(20, "DevAppEngine")
|
||||
});
|
||||
|
||||
sut.Dispose();
|
||||
|
||||
sut.ActiveProbeCount.ShouldBe(0);
|
||||
// After dispose, a Sync is a no-op.
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.ActiveProbeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_OnFreshManager_NoOp()
|
||||
{
|
||||
var client = new FakeMxAccessClient();
|
||||
var sut = Sut(client, 15, new List<int>(), new List<int>());
|
||||
|
||||
Should.NotThrow(() => sut.Dispose());
|
||||
Should.NotThrow(() => sut.Dispose());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleProbeUpdate_AfterDispose_ReturnsFalse()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.Dispose();
|
||||
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------- IsHostStopped (Read-path short-circuit support) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task IsHostStopped_UnknownHost_ReturnsFalse()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
// Never delivered a callback — state is Unknown. Read-path should NOT short-circuit
|
||||
// on Unknown because the host might come online any moment.
|
||||
sut.IsHostStopped(20).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHostStopped_RunningHost_ReturnsFalse()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
sut.IsHostStopped(20).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHostStopped_StoppedHost_ReturnsTrue()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false));
|
||||
|
||||
sut.IsHostStopped(20).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHostStopped_AfterRecovery_ReturnsFalse()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(false));
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
sut.IsHostStopped(20).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHostStopped_UnknownGobjectId_ReturnsFalse()
|
||||
{
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
// Not a probed host — defensive false rather than throwing.
|
||||
sut.IsHostStopped(99999).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHostStopped_TransportDisconnected_UsesUnderlyingState()
|
||||
{
|
||||
// Critical contract: IsHostStopped is intended for the Read-path short-circuit and
|
||||
// uses the underlying state directly, NOT the GetSnapshot transport-gated rewrite.
|
||||
// When the transport is disconnected, MxAccess reads will fail via the normal error
|
||||
// path; we don't want IsHostStopped to double-flag the Read as stopped if the host
|
||||
// itself was actually Running before the transport dropped.
|
||||
var (client, stopSpy, runSpy) = NewSpyHarness();
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true));
|
||||
|
||||
client.State = ConnectionState.Disconnected;
|
||||
|
||||
// Running state preserved — short-circuit does NOT fire during transport outages.
|
||||
sut.IsHostStopped(20).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------- Subscribe failure rollback (stability review 2026-04-13 Finding 1) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_SubscribeThrows_DoesNotLeavePhantomEntry()
|
||||
{
|
||||
var client = new FakeMxAccessClient
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("advise failed")
|
||||
};
|
||||
var (stopSpy, runSpy) = (new List<int>(), new List<int>());
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
// A failed SubscribeAsync must not leave a phantom entry that Tick() can later
|
||||
// transition from Unknown to Stopped.
|
||||
sut.ActiveProbeCount.ShouldBe(0);
|
||||
sut.GetSnapshot().ShouldBeEmpty();
|
||||
sut.IsHostStopped(20).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_SubscribeThrows_TickDoesNotFireStopCallback()
|
||||
{
|
||||
var client = new FakeMxAccessClient
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("advise failed")
|
||||
};
|
||||
var clock = new Clock();
|
||||
var (stopSpy, runSpy) = (new List<int>(), new List<int>());
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy, clock);
|
||||
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
// Advance past the unknown timeout — if the rollback were incomplete, Tick() would
|
||||
// transition the phantom entry to Stopped and fan out a false host-down signal.
|
||||
clock.Now = clock.Now.AddSeconds(30);
|
||||
sut.Tick();
|
||||
|
||||
stopSpy.ShouldBeEmpty();
|
||||
runSpy.ShouldBeEmpty();
|
||||
sut.ActiveProbeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_SubscribeSucceedsAfterRetry_AppearsInSnapshot()
|
||||
{
|
||||
// After a failed subscribe rolls back cleanly, a subsequent successful SyncAsync
|
||||
// against the same host must behave normally.
|
||||
var client = new FakeMxAccessClient
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("first attempt fails")
|
||||
};
|
||||
var (stopSpy, runSpy) = (new List<int>(), new List<int>());
|
||||
using var sut = Sut(client, 15, stopSpy, runSpy);
|
||||
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
sut.ActiveProbeCount.ShouldBe(0);
|
||||
|
||||
// Clear the fault and resync — the host must now appear with Unknown state.
|
||||
client.SubscribeException = null;
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
sut.ActiveProbeCount.ShouldBe(1);
|
||||
sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Unknown);
|
||||
}
|
||||
|
||||
// ---------- Callback exception safety ----------
|
||||
|
||||
[Fact]
|
||||
public async Task TransitionCallback_ThrowsException_DoesNotCorruptState()
|
||||
{
|
||||
var client = new FakeMxAccessClient();
|
||||
Action<int> badCallback = _ => throw new InvalidOperationException("boom");
|
||||
using var sut = new GalaxyRuntimeProbeManager(client, 15, badCallback, badCallback);
|
||||
|
||||
await sut.SyncAsync(new[] { Engine(20, "DevAppEngine") });
|
||||
|
||||
Should.NotThrow(() => sut.HandleProbeUpdate("DevAppEngine.ScanState", Vtq.Good(true)));
|
||||
sut.GetSnapshot().Single().State.ShouldBe(GalaxyRuntimeState.Running);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static GalaxyRuntimeProbeManager Sut(
|
||||
FakeMxAccessClient client,
|
||||
int timeoutSeconds,
|
||||
List<int> stopSpy,
|
||||
List<int> runSpy,
|
||||
Clock? clock = null)
|
||||
{
|
||||
clock ??= new Clock();
|
||||
return new GalaxyRuntimeProbeManager(
|
||||
client, timeoutSeconds,
|
||||
stopSpy.Add,
|
||||
runSpy.Add,
|
||||
() => clock.Now);
|
||||
}
|
||||
|
||||
private static (FakeMxAccessClient client, List<int> stopSpy, List<int> runSpy) NewSpyHarness()
|
||||
{
|
||||
return (new FakeMxAccessClient(), new List<int>(), new List<int>());
|
||||
}
|
||||
|
||||
private static GalaxyObjectInfo Platform(int id, string name) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
TagName = name,
|
||||
CategoryId = 1,
|
||||
HostedByGobjectId = 0
|
||||
};
|
||||
|
||||
private static GalaxyObjectInfo Engine(int id, string name) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
TagName = name,
|
||||
CategoryId = 3,
|
||||
HostedByGobjectId = 10
|
||||
};
|
||||
|
||||
private static GalaxyObjectInfo UserObject(int id, string name) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
TagName = name,
|
||||
CategoryId = 10,
|
||||
HostedByGobjectId = 20
|
||||
};
|
||||
|
||||
private static GalaxyObjectInfo AreaObject(int id, string name) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
TagName = name,
|
||||
CategoryId = 13,
|
||||
IsArea = true,
|
||||
HostedByGobjectId = 20
|
||||
};
|
||||
|
||||
private sealed class Clock
|
||||
{
|
||||
public DateTime Now { get; set; } = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies MXAccess client connection lifecycle behavior, including transitions, registration, and reconnect
|
||||
/// handling.
|
||||
/// </summary>
|
||||
public class MxAccessClientConnectionTests : IDisposable
|
||||
{
|
||||
private readonly MxAccessClient _client;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly List<(ConnectionState Previous, ConnectionState Current)> _stateChanges = new();
|
||||
private readonly StaComThread _staThread;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the connection test fixture with a fake runtime proxy and state-change recorder.
|
||||
/// </summary>
|
||||
public MxAccessClientConnectionTests()
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_proxy = new FakeMxProxy();
|
||||
_metrics = new PerformanceMetrics();
|
||||
var config = new MxAccessConfiguration();
|
||||
_client = new MxAccessClient(_staThread, _proxy, config, _metrics);
|
||||
_client.ConnectionStateChanged += (_, e) => _stateChanges.Add((e.PreviousState, e.CurrentState));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the connection test fixture and its supporting resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a newly created MXAccess client starts in the disconnected state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitialState_IsDisconnected()
|
||||
{
|
||||
_client.State.ShouldBe(ConnectionState.Disconnected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that connecting drives the expected disconnected-to-connecting-to-connected transitions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Connect_TransitionsToConnected()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
_client.State.ShouldBe(ConnectionState.Connected);
|
||||
_stateChanges.ShouldContain(s =>
|
||||
s.Previous == ConnectionState.Disconnected && s.Current == ConnectionState.Connecting);
|
||||
_stateChanges.ShouldContain(s =>
|
||||
s.Previous == ConnectionState.Connecting && s.Current == ConnectionState.Connected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a successful connect registers exactly once with the runtime proxy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Connect_RegistersCalled()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
_proxy.RegisterCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that disconnecting drives the expected shutdown transitions back to disconnected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Disconnect_TransitionsToDisconnected()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.DisconnectAsync();
|
||||
|
||||
_client.State.ShouldBe(ConnectionState.Disconnected);
|
||||
_stateChanges.ShouldContain(s => s.Current == ConnectionState.Disconnecting);
|
||||
_stateChanges.ShouldContain(s => s.Current == ConnectionState.Disconnected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that disconnecting unregisters the runtime proxy session.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Disconnect_UnregistersCalled()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.DisconnectAsync();
|
||||
_proxy.UnregisterCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that registration failures move the client into the error state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectFails_TransitionsToError()
|
||||
{
|
||||
_proxy.ShouldFailRegister = true;
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(_client.ConnectAsync());
|
||||
_client.State.ShouldBe(ConnectionState.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that repeated connect calls do not perform duplicate runtime registrations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DoubleConnect_NoOp()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.ConnectAsync(); // Should be no-op
|
||||
_proxy.RegisterCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that reconnect increments the reconnect counter and restores the connected state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reconnect_IncrementsCount()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
_client.ReconnectCount.ShouldBe(0);
|
||||
|
||||
await _client.ReconnectAsync();
|
||||
_client.ReconnectCount.ShouldBe(1);
|
||||
_client.State.ShouldBe(ConnectionState.Connected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the background connectivity monitor used to reconnect the MXAccess bridge after faults or stale probes.
|
||||
/// </summary>
|
||||
public class MxAccessClientMonitorTests : IDisposable
|
||||
{
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly StaComThread _staThread;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the monitor test fixture with a shared STA thread, fake proxy, and metrics collector.
|
||||
/// </summary>
|
||||
public MxAccessClientMonitorTests()
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_proxy = new FakeMxProxy();
|
||||
_metrics = new PerformanceMetrics();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the monitor test fixture resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the monitor reconnects the client after an observed disconnect.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Monitor_ReconnectsOnDisconnect()
|
||||
{
|
||||
var config = new MxAccessConfiguration
|
||||
{
|
||||
MonitorIntervalSeconds = 1,
|
||||
AutoReconnect = true
|
||||
};
|
||||
var client = new MxAccessClient(_staThread, _proxy, config, _metrics);
|
||||
|
||||
await client.ConnectAsync();
|
||||
await client.DisconnectAsync();
|
||||
|
||||
client.StartMonitor();
|
||||
|
||||
// Wait for monitor to detect disconnect and reconnect
|
||||
await Task.Delay(2500);
|
||||
|
||||
client.StopMonitor();
|
||||
client.State.ShouldBe(ConnectionState.Connected);
|
||||
client.ReconnectCount.ShouldBeGreaterThan(0);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the monitor can be started and stopped without throwing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Monitor_StopsOnCancel()
|
||||
{
|
||||
var config = new MxAccessConfiguration { MonitorIntervalSeconds = 1 };
|
||||
var client = new MxAccessClient(_staThread, _proxy, config, _metrics);
|
||||
|
||||
await client.ConnectAsync();
|
||||
client.StartMonitor();
|
||||
client.StopMonitor();
|
||||
|
||||
// Should not throw
|
||||
await Task.Delay(200);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a stale probe tag triggers a reconnect when monitoring is enabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Monitor_ProbeStale_ForcesReconnect()
|
||||
{
|
||||
var config = new MxAccessConfiguration
|
||||
{
|
||||
ProbeTag = "TestProbe",
|
||||
ProbeStaleThresholdSeconds = 2,
|
||||
MonitorIntervalSeconds = 1,
|
||||
AutoReconnect = true
|
||||
};
|
||||
var client = new MxAccessClient(_staThread, _proxy, config, _metrics);
|
||||
|
||||
await client.ConnectAsync();
|
||||
client.StartMonitor();
|
||||
|
||||
// Wait long enough for probe to go stale (threshold=2s, monitor interval=1s)
|
||||
// No data changes simulated → probe becomes stale → reconnect triggered
|
||||
await Task.Delay(4000);
|
||||
|
||||
client.StopMonitor();
|
||||
client.ReconnectCount.ShouldBeGreaterThan(0);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that fresh probe updates prevent unnecessary reconnects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Monitor_ProbeDataChange_PreventsStaleReconnect()
|
||||
{
|
||||
var config = new MxAccessConfiguration
|
||||
{
|
||||
ProbeTag = "TestProbe",
|
||||
ProbeStaleThresholdSeconds = 5,
|
||||
MonitorIntervalSeconds = 1,
|
||||
AutoReconnect = true
|
||||
};
|
||||
var client = new MxAccessClient(_staThread, _proxy, config, _metrics);
|
||||
|
||||
await client.ConnectAsync();
|
||||
client.StartMonitor();
|
||||
|
||||
// Continuously simulate probe data changes to keep it fresh
|
||||
// Stale threshold (5s) is well above the delay (500ms) to avoid timing flakes
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
_proxy.SimulateDataChangeByAddress("TestProbe", i);
|
||||
}
|
||||
|
||||
client.StopMonitor();
|
||||
// Probe was kept fresh → no reconnect should have happened
|
||||
client.ReconnectCount.ShouldBe(0);
|
||||
client.State.ShouldBe(ConnectionState.Connected);
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that enabling the monitor without a probe tag does not trigger false reconnects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Monitor_NoProbeConfigured_NoFalseReconnect()
|
||||
{
|
||||
var config = new MxAccessConfiguration
|
||||
{
|
||||
ProbeTag = null, // No probe
|
||||
MonitorIntervalSeconds = 1,
|
||||
AutoReconnect = true
|
||||
};
|
||||
var client = new MxAccessClient(_staThread, _proxy, config, _metrics);
|
||||
|
||||
await client.ConnectAsync();
|
||||
client.StartMonitor();
|
||||
|
||||
// Wait several monitor cycles — should stay connected with no reconnects
|
||||
await Task.Delay(3000);
|
||||
|
||||
client.StopMonitor();
|
||||
client.State.ShouldBe(ConnectionState.Connected);
|
||||
client.ReconnectCount.ShouldBe(0);
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies MXAccess client read and write behavior against the fake runtime proxy used by the bridge.
|
||||
/// </summary>
|
||||
public class MxAccessClientReadWriteTests : IDisposable
|
||||
{
|
||||
private readonly MxAccessClient _client;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly StaComThread _staThread;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the COM-threaded MXAccess test fixture with a fake runtime proxy and metrics collector.
|
||||
/// </summary>
|
||||
public MxAccessClientReadWriteTests()
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_proxy = new FakeMxProxy();
|
||||
_metrics = new PerformanceMetrics();
|
||||
var config = new MxAccessConfiguration { ReadTimeoutSeconds = 2, WriteTimeoutSeconds = 2 };
|
||||
_client = new MxAccessClient(_staThread, _proxy, config, _metrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the MXAccess client fixture and its supporting STA thread and metrics collector.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that reads fail with bad-not-connected quality when the runtime session is offline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_NotConnected_ReturnsBad()
|
||||
{
|
||||
var result = await _client.ReadAsync("Tag.Attr");
|
||||
result.Quality.ShouldBe(Quality.BadNotConnected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a runtime data-change callback completes a pending read with the published value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_ReturnsValueOnDataChange()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
// Start read in background
|
||||
var readTask = _client.ReadAsync("TestTag.Attr");
|
||||
|
||||
// Give it a moment to set up subscription, then simulate data change
|
||||
await Task.Delay(50);
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", 42);
|
||||
|
||||
var result = await readTask;
|
||||
result.Value.ShouldBe(42);
|
||||
result.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that reads time out with bad communication-failure quality when the runtime never responds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_Timeout_ReturnsBadCommFailure()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
// No data change simulated, so it will timeout
|
||||
var result = await _client.ReadAsync("TestTag.Attr");
|
||||
result.Quality.ShouldBe(Quality.BadCommFailure);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that timed-out reads are recorded as failed read operations in the metrics collector.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_Timeout_RecordsFailedMetrics()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
var result = await _client.ReadAsync("TestTag.Attr");
|
||||
result.Quality.ShouldBe(Quality.BadCommFailure);
|
||||
|
||||
var stats = _metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Read");
|
||||
stats["Read"].TotalCount.ShouldBe(1);
|
||||
stats["Read"].SuccessCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that writes are rejected when the runtime session is not connected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_NotConnected_ReturnsFalse()
|
||||
{
|
||||
var result = await _client.WriteAsync("Tag.Attr", 42);
|
||||
result.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that successful runtime write acknowledgments return success and record the written payload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_Success_ReturnsTrue()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
_proxy.WriteCompleteStatus = 0;
|
||||
|
||||
var result = await _client.WriteAsync("TestTag.Attr", 42);
|
||||
result.ShouldBe(true);
|
||||
_proxy.WrittenValues.ShouldContain(w => w.Address == "TestTag.Attr" && (int)w.Value == 42);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that MXAccess error codes on write completion are surfaced as failed writes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_ErrorCode_ReturnsFalse()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
_proxy.WriteCompleteStatus = 1012; // Wrong data type
|
||||
|
||||
var result = await _client.WriteAsync("TestTag.Attr", "bad_value");
|
||||
result.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that write timeouts are recorded as failed write operations in the metrics collector.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_Timeout_ReturnsFalse_AndRecordsFailedMetrics()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
_proxy.SkipWriteCompleteCallback = true;
|
||||
|
||||
var result = await _client.WriteAsync("TestTag.Attr", 42);
|
||||
result.ShouldBe(false);
|
||||
|
||||
var stats = _metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Write");
|
||||
stats["Write"].TotalCount.ShouldBe(1);
|
||||
stats["Write"].SuccessCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that successful reads contribute a read entry to the metrics collector.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_RecordsMetrics()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
var readTask = _client.ReadAsync("TestTag.Attr");
|
||||
await Task.Delay(50);
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", 1);
|
||||
await readTask;
|
||||
|
||||
var stats = _metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Read");
|
||||
stats["Read"].TotalCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that writes contribute a write entry to the metrics collector.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_RecordsMetrics()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.WriteAsync("TestTag.Attr", 42);
|
||||
|
||||
var stats = _metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Write");
|
||||
stats["Write"].TotalCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies how the MXAccess client manages persistent subscriptions, reconnect replay, and probe-tag behavior.
|
||||
/// </summary>
|
||||
public class MxAccessClientSubscriptionTests : IDisposable
|
||||
{
|
||||
private readonly MxAccessClient _client;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly StaComThread _staThread;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the subscription test fixture with a fake runtime proxy and STA thread.
|
||||
/// </summary>
|
||||
public MxAccessClientSubscriptionTests()
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_proxy = new FakeMxProxy();
|
||||
_metrics = new PerformanceMetrics();
|
||||
_client = new MxAccessClient(_staThread, _proxy, new MxAccessConfiguration(), _metrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the subscription test fixture and its supporting resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscribing creates a runtime item, advises it, and increments the active subscription count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_CreatesItemAndAdvises()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||
|
||||
_proxy.Items.Count.ShouldBeGreaterThan(0);
|
||||
_proxy.AdvisedItems.Count.ShouldBeGreaterThan(0);
|
||||
_client.ActiveSubscriptionCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscribing to the same address twice reuses the existing runtime item.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_SameAddressTwice_ReusesExistingRuntimeItem()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||
|
||||
_client.ActiveSubscriptionCount.ShouldBe(1);
|
||||
_proxy.Items.Values.Count(v => v == "TestTag.Attr").ShouldBe(1);
|
||||
|
||||
await _client.UnsubscribeAsync("TestTag.Attr");
|
||||
|
||||
_proxy.Items.Values.ShouldNotContain("TestTag.Attr");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unsubscribing clears the active subscription count after a tag was previously monitored.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unsubscribe_RemovesItemAndUnadvises()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||
await _client.UnsubscribeAsync("TestTag.Attr");
|
||||
|
||||
_client.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that runtime data changes are delivered to the per-subscription callback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnDataChange_InvokesCallback()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
Vtq? received = null;
|
||||
await _client.SubscribeAsync("TestTag.Attr", (addr, vtq) => received = vtq);
|
||||
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", 42);
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received.Value.Value.ShouldBe(42);
|
||||
received.Value.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that runtime data changes are also delivered to the client's global tag-change event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnDataChange_InvokesGlobalHandler()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
string? globalAddr = null;
|
||||
_client.OnTagValueChanged += (addr, vtq) => globalAddr = addr;
|
||||
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", "hello");
|
||||
|
||||
globalAddr.ShouldBe("TestTag.Attr");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that stored subscriptions are replayed after reconnect so live updates resume automatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StoredSubscriptions_ReplayedAfterReconnect()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
var callbackInvoked = false;
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true);
|
||||
|
||||
// Reconnect
|
||||
await _client.ReconnectAsync();
|
||||
|
||||
// After reconnect, subscription should be replayed
|
||||
_client.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
// Simulate data change on the re-subscribed item
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", "value");
|
||||
callbackInvoked.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that one-shot reads do not remove persistent subscriptions when the client reconnects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OneShotRead_DoesNotRemovePersistentSubscription_OnReconnect()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
var callbackInvoked = false;
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => callbackInvoked = true);
|
||||
|
||||
var readTask = _client.ReadAsync("TestTag.Attr");
|
||||
await Task.Delay(50);
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", 42);
|
||||
(await readTask).Value.ShouldBe(42);
|
||||
callbackInvoked = false;
|
||||
|
||||
await _client.ReconnectAsync();
|
||||
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", "after_reconnect");
|
||||
callbackInvoked.ShouldBe(true);
|
||||
_client.ActiveSubscriptionCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that transient writes do not prevent later removal of a persistent subscription.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OneShotWrite_DoesNotBreakPersistentUnsubscribe()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||
_proxy.Items.Values.ShouldContain("TestTag.Attr");
|
||||
|
||||
var writeResult = await _client.WriteAsync("TestTag.Attr", 7);
|
||||
writeResult.ShouldBe(true);
|
||||
|
||||
await _client.UnsubscribeAsync("TestTag.Attr");
|
||||
|
||||
_client.ActiveSubscriptionCount.ShouldBe(0);
|
||||
_proxy.Items.Values.ShouldNotContain("TestTag.Attr");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the configured probe tag is subscribed during connect so connectivity monitoring can start
|
||||
/// immediately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeTag_SubscribedOnConnect()
|
||||
{
|
||||
var proxy = new FakeMxProxy();
|
||||
var config = new MxAccessConfiguration { ProbeTag = "TestProbe" };
|
||||
var client = new MxAccessClient(_staThread, proxy, config, _metrics);
|
||||
|
||||
await client.ConnectAsync();
|
||||
|
||||
// Probe tag should be subscribed (present in proxy items)
|
||||
proxy.Items.Values.ShouldContain("TestProbe");
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the probe tag cannot be unsubscribed accidentally because it is reserved for connection monitoring.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeTag_ProtectedFromUnsubscribe()
|
||||
{
|
||||
var proxy = new FakeMxProxy();
|
||||
var config = new MxAccessConfiguration { ProbeTag = "TestProbe" };
|
||||
var client = new MxAccessClient(_staThread, proxy, config, _metrics);
|
||||
|
||||
await client.ConnectAsync();
|
||||
proxy.Items.Values.ShouldContain("TestProbe");
|
||||
|
||||
// Attempt to unsubscribe the probe tag — should be protected
|
||||
await client.UnsubscribeAsync("TestProbe");
|
||||
|
||||
// Probe should still be in the proxy items (not removed)
|
||||
proxy.Items.Values.ShouldContain("TestProbe");
|
||||
client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
124
tests/ZB.MOM.WW.OtOpcUa.Tests/MxAccess/StaComThreadTests.cs
Normal file
124
tests/ZB.MOM.WW.OtOpcUa.Tests/MxAccess/StaComThreadTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the single-threaded apartment worker used to marshal COM calls for the MXAccess bridge.
|
||||
/// </summary>
|
||||
public class StaComThreadTests : IDisposable
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void PostQuitMessage(int nExitCode);
|
||||
|
||||
private readonly StaComThread _thread;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a fresh STA thread instance for each test.
|
||||
/// </summary>
|
||||
public StaComThreadTests()
|
||||
{
|
||||
_thread = new StaComThread();
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the STA thread after each test.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_thread.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that queued work runs on a thread configured for STA apartment state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ExecutesOnStaThread()
|
||||
{
|
||||
var apartmentState = await _thread.RunAsync(() => Thread.CurrentThread.GetApartmentState());
|
||||
apartmentState.ShouldBe(ApartmentState.STA);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that action delegates run to completion on the STA thread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Action_Completes()
|
||||
{
|
||||
var executed = false;
|
||||
await _thread.RunAsync(() => executed = true);
|
||||
executed.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that function delegates can return results from the STA thread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_Func_ReturnsResult()
|
||||
{
|
||||
var result = await _thread.RunAsync(() => 42);
|
||||
result.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that exceptions thrown on the STA thread propagate back to the caller.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_PropagatesException()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
_thread.RunAsync(() => throw new InvalidOperationException("test error")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that disposing the STA thread stops it from accepting additional work.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Dispose_Stops_Thread()
|
||||
{
|
||||
var thread = new StaComThread();
|
||||
thread.Start();
|
||||
thread.IsRunning.ShouldBe(true);
|
||||
thread.Dispose();
|
||||
// After dispose, should not accept new work
|
||||
Should.Throw<ObjectDisposedException>(() => thread.RunAsync(() => { }).GetAwaiter().GetResult());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that multiple queued work items all execute successfully on the STA thread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultipleWorkItems_ExecuteInOrder()
|
||||
{
|
||||
var results = new ConcurrentBag<int>();
|
||||
await Task.WhenAll(
|
||||
_thread.RunAsync(() => results.Add(1)),
|
||||
_thread.RunAsync(() => results.Add(2)),
|
||||
_thread.RunAsync(() => results.Add(3)));
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that after the message pump exits, subsequent RunAsync calls throw instead of hanging.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AfterPumpExit_ThrowsInsteadOfHanging()
|
||||
{
|
||||
// Kill the pump from inside by posting WM_QUIT
|
||||
await _thread.RunAsync(() => PostQuitMessage(0));
|
||||
await Task.Delay(100); // let pump exit
|
||||
|
||||
_thread.IsRunning.ShouldBe(false);
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
_thread.RunAsync(() => { }).GetAwaiter().GetResult());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user