Phase 3 PR 18 — delete v1 archived projects. PR 2 archived via IsTestProject=false + PropertyGroup comment; PR 17 landed the full v2 OPC UA server runtime (ApplicationConfiguration + endpoint + client integration test); every v1 surface is now functionally superseded. This PR removes the archive: 154 files across 5 projects — src/OtOpcUa.Host (v1 server, 158 files), src/Historian.Aveva (v1 historian plugin, 4 files), tests/OtOpcUa.Tests.v1Archive (494 unit tests that were archived in PR 2 with IsTestProject=false), tests/Historian.Aveva.Tests (18 tests against the v1 plugin), tests/OtOpcUa.IntegrationTests (6 tests against the v1 Host). slnx trimmed to reflect the current set (12 src + 12 tests). Verified zero incoming references from live projects before deleting — no live csproj references .Host or .Historian.Aveva since PR 5 ported Historian into Driver.Galaxy.Host/Backend/Historian/ and PR 17 stood up the new OtOpcUa.Server. Full solution post-delete: 0 errors, 165 unit + integration tests pass (8 Core + 14 Proxy + 24 Configuration + 91 Galaxy.Host + 6 Shared + 4 Server + 18 Admin) — no regressions. Recovery path if a future PR needs to resurrect a specific v1 routine: git revert this commit or cherry-pick the specific file from pre-delete history; v1 is preserved in the full branch history, not lost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ArchestrA;
|
||||
using ZB.MOM.WW.OtOpcUa.Historian.Aveva;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake Historian connection factory for tests. Controls whether connections
|
||||
/// succeed, fail, or timeout without requiring the real Historian SDK runtime.
|
||||
/// </summary>
|
||||
internal sealed class FakeHistorianConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception thrown on every CreateAndConnect call unless a more specific rule in
|
||||
/// <see cref="ServerBehaviors"/> or <see cref="OnConnect"/> fires first.
|
||||
/// </summary>
|
||||
public Exception? ConnectException { get; set; }
|
||||
|
||||
public int ConnectCallCount { get; private set; }
|
||||
|
||||
public Action<int>? OnConnect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-server-name override: if the requested <c>config.ServerName</c> has an entry
|
||||
/// whose value is non-null, that exception is thrown instead of the global
|
||||
/// <see cref="ConnectException"/>. Lets tests script cluster failover behavior like
|
||||
/// "node A always fails; node B always succeeds".
|
||||
/// </summary>
|
||||
public Dictionary<string, Exception?> ServerBehaviors { get; } =
|
||||
new Dictionary<string, Exception?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Ordered history of server names passed to CreateAndConnect so tests can assert the
|
||||
/// picker's iteration order and failover sequence.
|
||||
/// </summary>
|
||||
public List<string> ConnectHistory { get; } = new List<string>();
|
||||
|
||||
public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
|
||||
{
|
||||
ConnectCallCount++;
|
||||
ConnectHistory.Add(config.ServerName);
|
||||
|
||||
if (ServerBehaviors.TryGetValue(config.ServerName, out var serverException) && serverException != null)
|
||||
throw serverException;
|
||||
|
||||
if (OnConnect != null)
|
||||
{
|
||||
OnConnect(ConnectCallCount);
|
||||
}
|
||||
else if (ConnectException != null)
|
||||
{
|
||||
throw ConnectException;
|
||||
}
|
||||
|
||||
// Return a HistorianAccess that is not actually connected.
|
||||
// ReadRawAsync etc. will fail when they try to use it, which exercises
|
||||
// the HandleConnectionError → reconnect path.
|
||||
return new HistorianAccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Exhaustive coverage of the cluster endpoint picker: config parsing, healthy-list ordering,
|
||||
/// cooldown behavior with an injected clock, and thread-safety under concurrent writers.
|
||||
/// </summary>
|
||||
public class HistorianClusterEndpointPickerTests
|
||||
{
|
||||
// ---------- Construction / config parsing ----------
|
||||
|
||||
[Fact]
|
||||
public void SingleServerName_FallbackWhenServerNamesEmpty()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(Config(serverName: "host-a"));
|
||||
picker.NodeCount.ShouldBe(1);
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "host-a" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerNames_TakesPrecedenceOverLegacyServerName()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverName: "legacy", serverNames: new[] { "host-a", "host-b" }));
|
||||
picker.NodeCount.ShouldBe(2);
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "host-a", "host-b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerNames_OrderedAsConfigured()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "c", "a", "b" }));
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "c", "a", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerNames_WhitespaceTrimmedAndEmptyDropped()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { " host-a ", "", " ", "host-b" }));
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "host-a", "host-b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerNames_CaseInsensitiveDeduplication()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "Host-A", "HOST-A", "host-a" }));
|
||||
picker.NodeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyConfig_ProducesEmptyPool()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverName: "", serverNames: Array.Empty<string>()));
|
||||
picker.NodeCount.ShouldBe(0);
|
||||
picker.GetHealthyNodes().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---------- MarkFailed / cooldown window ----------
|
||||
|
||||
[Fact]
|
||||
public void MarkFailed_RemovesNodeFromHealthyList()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "boom");
|
||||
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
picker.HealthyNodeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkFailed_RecordsErrorAndTimestamp()
|
||||
{
|
||||
var clock = new FakeClock { UtcNow = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) };
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a", "b" }), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "connection refused");
|
||||
|
||||
var states = picker.SnapshotNodeStates();
|
||||
var a = states.First(s => s.Name == "a");
|
||||
a.IsHealthy.ShouldBeFalse();
|
||||
a.FailureCount.ShouldBe(1);
|
||||
a.LastError.ShouldBe("connection refused");
|
||||
a.LastFailureTime.ShouldBe(clock.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkFailed_CooldownExpiryRestoresNode()
|
||||
{
|
||||
var clock = new FakeClock { UtcNow = new DateTime(2026, 4, 13, 10, 0, 0, DateTimeKind.Utc) };
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "boom");
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
|
||||
// Advance clock just before expiry — still in cooldown
|
||||
clock.UtcNow = clock.UtcNow.AddSeconds(59);
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
|
||||
// Advance past cooldown — node returns to pool
|
||||
clock.UtcNow = clock.UtcNow.AddSeconds(2);
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroCooldown_NeverBenchesNode()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 0), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "boom");
|
||||
|
||||
// Zero cooldown → node remains eligible immediately
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
|
||||
var state = picker.SnapshotNodeStates().First(s => s.Name == "a");
|
||||
state.FailureCount.ShouldBe(1);
|
||||
state.LastError.ShouldBe("boom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllNodesFailed_HealthyListIsEmpty()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 60), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "boom");
|
||||
picker.MarkFailed("b", "boom");
|
||||
|
||||
picker.GetHealthyNodes().ShouldBeEmpty();
|
||||
picker.HealthyNodeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkFailed_AccumulatesFailureCount()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a" }, cooldownSeconds: 10), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "error 1");
|
||||
clock.UtcNow = clock.UtcNow.AddSeconds(20); // recover
|
||||
picker.MarkFailed("a", "error 2");
|
||||
|
||||
picker.SnapshotNodeStates().First().FailureCount.ShouldBe(2);
|
||||
picker.SnapshotNodeStates().First().LastError.ShouldBe("error 2");
|
||||
}
|
||||
|
||||
// ---------- MarkHealthy ----------
|
||||
|
||||
[Fact]
|
||||
public void MarkHealthy_ClearsCooldownImmediately()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a", "b" }, cooldownSeconds: 3600), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "boom");
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
|
||||
picker.MarkHealthy("a");
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkHealthy_PreservesCumulativeFailureCount()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a" }), clock.Now);
|
||||
|
||||
picker.MarkFailed("a", "boom");
|
||||
picker.MarkHealthy("a");
|
||||
|
||||
var state = picker.SnapshotNodeStates().First();
|
||||
state.IsHealthy.ShouldBeTrue();
|
||||
state.FailureCount.ShouldBe(1); // history preserved
|
||||
}
|
||||
|
||||
// ---------- Unknown node handling ----------
|
||||
|
||||
[Fact]
|
||||
public void MarkFailed_UnknownNode_IsIgnored()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a" }), clock.Now);
|
||||
|
||||
Should.NotThrow(() => picker.MarkFailed("not-configured", "boom"));
|
||||
picker.GetHealthyNodes().ShouldBe(new[] { "a" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkHealthy_UnknownNode_IsIgnored()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a" }));
|
||||
Should.NotThrow(() => picker.MarkHealthy("not-configured"));
|
||||
}
|
||||
|
||||
// ---------- SnapshotNodeStates ----------
|
||||
|
||||
[Fact]
|
||||
public void SnapshotNodeStates_ReflectsConfigurationOrder()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "z", "m", "a" }));
|
||||
picker.SnapshotNodeStates().Select(s => s.Name).ShouldBe(new[] { "z", "m", "a" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotNodeStates_HealthyEntriesHaveNoCooldown()
|
||||
{
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a" }));
|
||||
var state = picker.SnapshotNodeStates().First();
|
||||
state.IsHealthy.ShouldBeTrue();
|
||||
state.CooldownUntil.ShouldBeNull();
|
||||
state.LastError.ShouldBeNull();
|
||||
state.LastFailureTime.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---------- Thread safety smoke test ----------
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentMarkAndQuery_DoesNotCorrupt()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var picker = new HistorianClusterEndpointPicker(
|
||||
Config(serverNames: new[] { "a", "b", "c", "d" }, cooldownSeconds: 5), clock.Now);
|
||||
|
||||
var tasks = new List<Task>();
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
for (var j = 0; j < 1000; j++)
|
||||
{
|
||||
picker.MarkFailed("a", "boom");
|
||||
picker.MarkHealthy("a");
|
||||
_ = picker.GetHealthyNodes();
|
||||
_ = picker.SnapshotNodeStates();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Task.WaitAll(tasks.ToArray());
|
||||
// Just verify we can still read state after the storm.
|
||||
picker.NodeCount.ShouldBe(4);
|
||||
picker.GetHealthyNodes().Count.ShouldBeInRange(3, 4);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static HistorianConfiguration Config(
|
||||
string serverName = "localhost",
|
||||
string[]? serverNames = null,
|
||||
int cooldownSeconds = 60)
|
||||
{
|
||||
return new HistorianConfiguration
|
||||
{
|
||||
ServerName = serverName,
|
||||
ServerNames = (serverNames ?? Array.Empty<string>()).ToList(),
|
||||
FailureCooldownSeconds = cooldownSeconds
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeClock
|
||||
{
|
||||
public DateTime UtcNow { get; set; } = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
public DateTime Now() => UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end behavior of the cluster endpoint picker wired into
|
||||
/// <see cref="HistorianDataSource"/>. Verifies that a failing node is skipped on the next
|
||||
/// attempt, that the picker state is shared across process + event silos, and that the
|
||||
/// health snapshot surfaces the winning node.
|
||||
/// </summary>
|
||||
public class HistorianClusterFailoverTests
|
||||
{
|
||||
private static HistorianConfiguration ClusterConfig(params string[] nodes) => new()
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = nodes.ToList(),
|
||||
Port = 32568,
|
||||
IntegratedSecurity = true,
|
||||
CommandTimeoutSeconds = 5,
|
||||
FailureCooldownSeconds = 60
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Connect_FirstNodeFails_PicksSecond()
|
||||
{
|
||||
// host-a fails during connect; host-b connects successfully. The fake returns an
|
||||
// unconnected HistorianAccess on success, so the query phase will subsequently trip
|
||||
// HandleConnectionError on host-b — that's expected. The observable signal is that
|
||||
// the picker tried host-a first, skipped to host-b, and host-a's failure was recorded.
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
|
||||
var config = ClusterConfig("host-a", "host-b");
|
||||
using var ds = new HistorianDataSource(config, factory);
|
||||
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b" });
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.NodeCount.ShouldBe(2);
|
||||
snap.Nodes.Single(n => n.Name == "host-a").IsHealthy.ShouldBeFalse();
|
||||
snap.Nodes.Single(n => n.Name == "host-a").FailureCount.ShouldBe(1);
|
||||
snap.Nodes.Single(n => n.Name == "host-a").LastError.ShouldContain("A down");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_AllNodesFail_ReturnsEmptyResults_AndAllInCooldown()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
|
||||
factory.ServerBehaviors["host-b"] = new InvalidOperationException("B down");
|
||||
var config = ClusterConfig("host-a", "host-b");
|
||||
using var ds = new HistorianDataSource(config, factory);
|
||||
|
||||
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
results.Count.ShouldBe(0);
|
||||
factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b" });
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.ActiveProcessNode.ShouldBeNull();
|
||||
snap.HealthyNodeCount.ShouldBe(0);
|
||||
snap.TotalFailures.ShouldBe(1); // one read call failed (after all cluster tries)
|
||||
snap.LastError.ShouldContain("All 2 healthy historian candidate(s) failed");
|
||||
snap.LastError.ShouldContain("B down"); // last inner exception preserved
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_SecondCall_SkipsCooledDownNode()
|
||||
{
|
||||
// After first call: host-a is in cooldown (60s), host-b is also marked failed via
|
||||
// HandleConnectionError since the fake connection doesn't support real queries.
|
||||
// Second call: both are in cooldown and the picker returns empty → the read method
|
||||
// catches the "all nodes failed" exception and returns empty without retrying connect.
|
||||
// We verify this by checking that the second call adds NOTHING to the connect history.
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
|
||||
var config = ClusterConfig("host-a", "host-b"); // 60s cooldown
|
||||
using var ds = new HistorianDataSource(config, factory);
|
||||
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
factory.ConnectHistory.Clear();
|
||||
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Both nodes are in cooldown → picker returns empty → factory is not called at all.
|
||||
results.Count.ShouldBe(0);
|
||||
factory.ConnectHistory.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_SingleNodeConfig_BehavesLikeLegacy()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
var config = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = "legacy-host",
|
||||
Port = 32568,
|
||||
FailureCooldownSeconds = 0
|
||||
};
|
||||
using var ds = new HistorianDataSource(config, factory);
|
||||
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
factory.ConnectHistory.ShouldBe(new[] { "legacy-host" });
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.NodeCount.ShouldBe(1);
|
||||
snap.Nodes.Single().Name.ShouldBe("legacy-host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_PickerOrderRespected()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
|
||||
factory.ServerBehaviors["host-b"] = new InvalidOperationException("B down");
|
||||
factory.ServerBehaviors["host-c"] = new InvalidOperationException("C down");
|
||||
var config = ClusterConfig("host-a", "host-b", "host-c");
|
||||
using var ds = new HistorianDataSource(config, factory);
|
||||
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Candidates are tried in configuration order.
|
||||
factory.ConnectHistory.ShouldBe(new[] { "host-a", "host-b", "host-c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_SharedPickerAcrossProcessAndEventSilos()
|
||||
{
|
||||
// Process path tries host-a, fails, then tries host-b. host-a is in cooldown. When
|
||||
// the event path subsequently starts with a 0s cooldown, the picker state is shared:
|
||||
// host-a is still marked failed (via its cooldown window) at the moment the event
|
||||
// silo asks. The event path therefore must not retry host-a.
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
factory.ServerBehaviors["host-a"] = new InvalidOperationException("A down");
|
||||
var config = ClusterConfig("host-a", "host-b");
|
||||
using var ds = new HistorianDataSource(config, factory);
|
||||
|
||||
// Process path: host-a fails → host-b reached (then torn down mid-query via the fake).
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// At this point host-a and host-b are both in cooldown. ReadEvents will hit the
|
||||
// picker's empty-healthy-list path and return empty without calling the factory.
|
||||
factory.ConnectHistory.Clear();
|
||||
var events = ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
events.Count.ShouldBe(0);
|
||||
factory.ConnectHistory.ShouldBeEmpty();
|
||||
// Critical assertion: host-a was NOT retried by the event silo — it's in the
|
||||
// shared cooldown from the process path's failure.
|
||||
factory.ConnectHistory.ShouldNotContain("host-a");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Historian.Aveva;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies Historian data source lifecycle behavior: dispose safety,
|
||||
/// post-dispose rejection, connection failure handling, and reconnect-after-error.
|
||||
/// </summary>
|
||||
public class HistorianDataSourceLifecycleTests
|
||||
{
|
||||
private static HistorianConfiguration DefaultConfig => new()
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = "test-historian",
|
||||
Port = 32568,
|
||||
IntegratedSecurity = true,
|
||||
CommandTimeoutSeconds = 5,
|
||||
// Zero cooldown so reconnect-after-error tests can retry through the cluster picker
|
||||
// on the very next call, matching the pre-cluster behavior they were written against.
|
||||
FailureCooldownSeconds = 0
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
var ds = new HistorianDataSource(DefaultConfig);
|
||||
ds.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadAggregateAsync_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
var ds = new HistorianDataSource(DefaultConfig);
|
||||
ds.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
|
||||
.GetAwaiter().GetResult());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadAtTimeAsync_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
var ds = new HistorianDataSource(DefaultConfig);
|
||||
ds.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow })
|
||||
.GetAwaiter().GetResult());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadEventsAsync_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
var ds = new HistorianDataSource(DefaultConfig);
|
||||
ds.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CalledTwice_DoesNotThrow()
|
||||
{
|
||||
var ds = new HistorianDataSource(DefaultConfig);
|
||||
ds.Dispose();
|
||||
Should.NotThrow(() => ds.Dispose());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistorianAggregateMap_UnknownColumn_ReturnsNull()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(new Opc.Ua.NodeId(99999)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_WhenConnectionFails_ReturnsEmptyResults()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("Connection refused")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
results.Count.ShouldBe(0);
|
||||
factory.ConnectCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_WhenConnectionTimesOut_ReturnsEmptyResults()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new TimeoutException("Connection timed out")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
results.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_AfterConnectionError_AttemptsReconnect()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
// First call: factory returns a HistorianAccess that isn't actually connected,
|
||||
// so the query will fail and HandleConnectionError will reset the connection.
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Second call: should attempt reconnection via the factory
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Factory should have been called twice — once for initial connect, once for reconnect
|
||||
factory.ConnectCallCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_ConnectionFailure_DoesNotCorruptState()
|
||||
{
|
||||
var callCount = 0;
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
OnConnect = count =>
|
||||
{
|
||||
callCount = count;
|
||||
if (count == 1)
|
||||
throw new InvalidOperationException("First connection fails");
|
||||
// Second call succeeds (returns unconnected HistorianAccess, but that's OK for lifecycle testing)
|
||||
}
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
// First read: connection fails
|
||||
var r1 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
r1.Count.ShouldBe(0);
|
||||
|
||||
// Second read: should attempt new connection without throwing from internal state corruption
|
||||
var r2 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
callCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DuringConnectionFailure_DoesNotThrow()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("Connection refused")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
// Trigger a failed connection attempt
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Dispose should handle the null connection gracefully
|
||||
Should.NotThrow(() => ds.Dispose());
|
||||
}
|
||||
|
||||
// ---------- HistorianHealthSnapshot instrumentation ----------
|
||||
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_FreshDataSource_ReportsZeroCounters()
|
||||
{
|
||||
var ds = new HistorianDataSource(DefaultConfig, new FakeHistorianConnectionFactory());
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
|
||||
snap.TotalQueries.ShouldBe(0);
|
||||
snap.TotalSuccesses.ShouldBe(0);
|
||||
snap.TotalFailures.ShouldBe(0);
|
||||
snap.ConsecutiveFailures.ShouldBe(0);
|
||||
snap.LastSuccessTime.ShouldBeNull();
|
||||
snap.LastFailureTime.ShouldBeNull();
|
||||
snap.LastError.ShouldBeNull();
|
||||
snap.ProcessConnectionOpen.ShouldBeFalse();
|
||||
snap.EventConnectionOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_AfterConnectionFailure_RecordsFailure()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("Connection refused")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.TotalQueries.ShouldBe(1);
|
||||
snap.TotalFailures.ShouldBe(1);
|
||||
snap.TotalSuccesses.ShouldBe(0);
|
||||
snap.ConsecutiveFailures.ShouldBe(1);
|
||||
snap.LastFailureTime.ShouldNotBeNull();
|
||||
snap.LastError.ShouldContain("Connection refused");
|
||||
snap.ProcessConnectionOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_AfterMultipleFailures_IncrementsConsecutive()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("boom")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.TotalFailures.ShouldBe(4);
|
||||
snap.ConsecutiveFailures.ShouldBe(4);
|
||||
snap.TotalSuccesses.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_AcrossReadPaths_CountsAllFailures()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("sdk down")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
|
||||
.GetAwaiter().GetResult();
|
||||
ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow })
|
||||
.GetAwaiter().GetResult();
|
||||
ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 10)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.TotalFailures.ShouldBe(4);
|
||||
snap.TotalQueries.ShouldBe(4);
|
||||
snap.LastError.ShouldContain("sdk down");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_ErrorMessageCarriesReadPath()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("unreachable")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.LastError.ShouldStartWith("aggregate:");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.2.1"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Historian.Aveva\ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Tests reference aahClientManaged so the FakeHistorianConnectionFactory can
|
||||
implement the SDK-typed IHistorianConnectionFactory interface. -->
|
||||
<Reference Include="aahClientManaged">
|
||||
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="aahClientCommon">
|
||||
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,129 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests that exercise the real Galaxy repository queries against the test database configuration.
|
||||
/// </summary>
|
||||
public class GalaxyRepositoryServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads repository configuration from the integration test settings and controls whether extended attributes are
|
||||
/// enabled.
|
||||
/// </summary>
|
||||
/// <param name="extendedAttributes">A value indicating whether the extended attribute query path should be enabled.</param>
|
||||
/// <returns>The repository configuration used by the integration test.</returns>
|
||||
private static GalaxyRepositoryConfiguration LoadConfig(bool extendedAttributes = false)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.test.json", false)
|
||||
.Build();
|
||||
|
||||
var config = new GalaxyRepositoryConfiguration();
|
||||
configuration.GetSection("GalaxyRepository").Bind(config);
|
||||
config.ExtendedAttributes = extendedAttributes;
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the standard attribute query returns rows from the repository.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAttributesAsync_StandardMode_ReturnsRows()
|
||||
{
|
||||
var config = LoadConfig(false);
|
||||
var service = new GalaxyRepositoryService(config);
|
||||
|
||||
var results = await service.GetAttributesAsync();
|
||||
|
||||
results.ShouldNotBeEmpty();
|
||||
// Standard mode: PrimitiveName and AttributeSource should be empty
|
||||
results.ShouldAllBe(r => r.PrimitiveName == "" && r.AttributeSource == "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the extended attribute query returns more rows than the standard query path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAttributesAsync_ExtendedMode_ReturnsMoreRows()
|
||||
{
|
||||
var standardConfig = LoadConfig(false);
|
||||
var extendedConfig = LoadConfig(true);
|
||||
var standardService = new GalaxyRepositoryService(standardConfig);
|
||||
var extendedService = new GalaxyRepositoryService(extendedConfig);
|
||||
|
||||
var standardResults = await standardService.GetAttributesAsync();
|
||||
var extendedResults = await extendedService.GetAttributesAsync();
|
||||
|
||||
extendedResults.Count.ShouldBeGreaterThan(standardResults.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the extended attribute query includes both primitive and dynamic attribute sources.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAttributesAsync_ExtendedMode_IncludesPrimitiveAttributes()
|
||||
{
|
||||
var config = LoadConfig(true);
|
||||
var service = new GalaxyRepositoryService(config);
|
||||
|
||||
var results = await service.GetAttributesAsync();
|
||||
|
||||
results.ShouldContain(r => r.AttributeSource == "primitive");
|
||||
results.ShouldContain(r => r.AttributeSource == "dynamic");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that extended mode populates attribute-source metadata across the result set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAttributesAsync_ExtendedMode_PrimitiveNamePopulated()
|
||||
{
|
||||
var config = LoadConfig(true);
|
||||
var service = new GalaxyRepositoryService(config);
|
||||
|
||||
var results = await service.GetAttributesAsync();
|
||||
|
||||
// Some primitive attributes have non-empty primitive names
|
||||
// (though many have empty primitive_name for the root UDO)
|
||||
results.ShouldNotBeEmpty();
|
||||
// All should have an attribute source
|
||||
results.ShouldAllBe(r => r.AttributeSource == "primitive" || r.AttributeSource == "dynamic");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that standard-mode results always include fully qualified tag references.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAttributesAsync_StandardMode_AllHaveFullTagReference()
|
||||
{
|
||||
var config = LoadConfig(false);
|
||||
var service = new GalaxyRepositoryService(config);
|
||||
|
||||
var results = await service.GetAttributesAsync();
|
||||
|
||||
results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference));
|
||||
results.ShouldAllBe(r => r.FullTagReference.Contains("."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that extended-mode results always include fully qualified tag references.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAttributesAsync_ExtendedMode_AllHaveFullTagReference()
|
||||
{
|
||||
var config = LoadConfig(true);
|
||||
var service = new GalaxyRepositoryService(config);
|
||||
|
||||
var results = await service.GetAttributesAsync();
|
||||
|
||||
results.ShouldAllBe(r => !string.IsNullOrEmpty(r.FullTagReference));
|
||||
results.ShouldAllBe(r => r.FullTagReference.Contains("."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. References v1 OtOpcUa.Host directly.
|
||||
Excluded from `dotnet test` solution runs; replaced by the v2
|
||||
OtOpcUa.Driver.Galaxy.E2E suite. To run explicitly:
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
<IsTestProject>false</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.2.1"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.test.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="xunit.runner.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"GalaxyRepository": {
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Authentication
|
||||
{
|
||||
public class UserAuthenticationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuthenticationConfiguration_Defaults()
|
||||
{
|
||||
var config = new AuthenticationConfiguration();
|
||||
|
||||
config.AllowAnonymous.ShouldBeTrue();
|
||||
config.AnonymousCanWrite.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthenticationConfiguration_LdapDefaults()
|
||||
{
|
||||
var config = new AuthenticationConfiguration();
|
||||
|
||||
config.Ldap.ShouldNotBeNull();
|
||||
config.Ldap.Enabled.ShouldBeFalse();
|
||||
config.Ldap.Host.ShouldBe("localhost");
|
||||
config.Ldap.Port.ShouldBe(3893);
|
||||
config.Ldap.BaseDN.ShouldBe("dc=lmxopcua,dc=local");
|
||||
config.Ldap.ReadOnlyGroup.ShouldBe("ReadOnly");
|
||||
config.Ldap.WriteOperateGroup.ShouldBe("WriteOperate");
|
||||
config.Ldap.WriteTuneGroup.ShouldBe("WriteTune");
|
||||
config.Ldap.WriteConfigureGroup.ShouldBe("WriteConfigure");
|
||||
config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck");
|
||||
config.Ldap.TimeoutSeconds.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapConfiguration_BindDnTemplate_Default()
|
||||
{
|
||||
var config = new LdapConfiguration();
|
||||
config.BindDnTemplate.ShouldBe("cn={username},dc=lmxopcua,dc=local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ValidBind_ReturnsTrue()
|
||||
{
|
||||
// This test requires GLAuth running on localhost:3893
|
||||
// Skip if not available
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// GLAuth not running - skip gracefully
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_InvalidPassword_ReturnsFalse()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("readonly", "wrongpassword").ShouldBeFalse();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_UnknownUser_ReturnsFalse()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("nonexistent", "anything").ShouldBeFalse();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ReadOnlyUser_HasReadOnlyRole()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("readonly");
|
||||
roles.ShouldContain("ReadOnly");
|
||||
roles.ShouldNotContain("WriteOperate");
|
||||
roles.ShouldNotContain("AlarmAck");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_WriteOperateUser_HasWriteOperateRole()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("writeop", "writeop123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("writeop");
|
||||
roles.ShouldContain("WriteOperate");
|
||||
roles.ShouldNotContain("AlarmAck");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_AlarmAckUser_HasAlarmAckRole()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("alarmack");
|
||||
roles.ShouldContain("AlarmAck");
|
||||
roles.ShouldNotContain("WriteOperate");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_AdminUser_HasAllRoles()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("admin", "admin123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("admin");
|
||||
roles.ShouldContain("ReadOnly");
|
||||
roles.ShouldContain("WriteOperate");
|
||||
roles.ShouldContain("WriteTune");
|
||||
roles.ShouldContain("WriteConfigure");
|
||||
roles.ShouldContain("AlarmAck");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ImplementsIRoleProvider()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
(provider is IRoleProvider).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse()
|
||||
{
|
||||
var ldapConfig = new LdapConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Host = "localhost",
|
||||
Port = 19999, // no server here
|
||||
TimeoutSeconds = 1
|
||||
};
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
provider.ValidateCredentials("anyone", "anything").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ConnectionFailure_GetUserRoles_FallsBackToReadOnly()
|
||||
{
|
||||
var ldapConfig = new LdapConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Host = "localhost",
|
||||
Port = 19999, // no server here
|
||||
TimeoutSeconds = 1,
|
||||
ServiceAccountDn = "cn=svc,dc=test",
|
||||
ServiceAccountPassword = "test"
|
||||
};
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
var roles = provider.GetUserRoles("anyone");
|
||||
roles.ShouldContain("ReadOnly");
|
||||
}
|
||||
|
||||
private static LdapConfiguration CreateGlAuthConfig()
|
||||
{
|
||||
return new LdapConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Host = "localhost",
|
||||
Port = 3893,
|
||||
BaseDN = "dc=lmxopcua,dc=local",
|
||||
BindDnTemplate = "cn={username},dc=lmxopcua,dc=local",
|
||||
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
ServiceAccountPassword = "serviceaccount123",
|
||||
TimeoutSeconds = 5,
|
||||
ReadOnlyGroup = "ReadOnly",
|
||||
WriteOperateGroup = "WriteOperate",
|
||||
WriteTuneGroup = "WriteTune",
|
||||
WriteConfigureGroup = "WriteConfigure",
|
||||
AlarmAckGroup = "AlarmAck"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that application configuration binds correctly from appsettings and that validation catches invalid bridge
|
||||
/// settings.
|
||||
/// </summary>
|
||||
public class ConfigurationLoadingTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the application configuration from the repository appsettings file for binding tests.
|
||||
/// </summary>
|
||||
/// <returns>The bound application configuration snapshot.</returns>
|
||||
private static AppConfiguration LoadFromJson()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", false)
|
||||
.Build();
|
||||
|
||||
var config = new AppConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(config.OpcUa);
|
||||
configuration.GetSection("MxAccess").Bind(config.MxAccess);
|
||||
configuration.GetSection("GalaxyRepository").Bind(config.GalaxyRepository);
|
||||
configuration.GetSection("Dashboard").Bind(config.Dashboard);
|
||||
configuration.GetSection("Security").Bind(config.Security);
|
||||
configuration.GetSection("Historian").Bind(config.Historian);
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the OPC UA section binds the endpoint and session settings expected by the bridge.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUa_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
|
||||
config.OpcUa.Port.ShouldBe(4840);
|
||||
config.OpcUa.EndpointPath.ShouldBe("/LmxOpcUa");
|
||||
config.OpcUa.ServerName.ShouldBe("LmxOpcUa");
|
||||
config.OpcUa.GalaxyName.ShouldBe("ZB");
|
||||
config.OpcUa.MaxSessions.ShouldBe(100);
|
||||
config.OpcUa.SessionTimeoutMinutes.ShouldBe(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the MXAccess section binds runtime timeout and reconnect settings correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MxAccess_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
|
||||
config.MxAccess.ReadTimeoutSeconds.ShouldBe(5);
|
||||
config.MxAccess.WriteTimeoutSeconds.ShouldBe(5);
|
||||
config.MxAccess.MaxConcurrentOperations.ShouldBe(10);
|
||||
config.MxAccess.MonitorIntervalSeconds.ShouldBe(5);
|
||||
config.MxAccess.AutoReconnect.ShouldBe(true);
|
||||
config.MxAccess.ProbeStaleThresholdSeconds.ShouldBe(60);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the Galaxy repository section binds connection and polling settings correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GalaxyRepository_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.GalaxyRepository.ConnectionString.ShouldContain("ZB");
|
||||
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
|
||||
config.GalaxyRepository.CommandTimeoutSeconds.ShouldBe(30);
|
||||
config.GalaxyRepository.ExtendedAttributes.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that extended-attribute loading defaults to disabled when not configured.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GalaxyRepository_ExtendedAttributes_DefaultsFalse()
|
||||
{
|
||||
var config = new GalaxyRepositoryConfiguration();
|
||||
config.ExtendedAttributes.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the extended-attribute flag can be enabled through configuration binding.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GalaxyRepository_ExtendedAttributes_BindsFromJson()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", false)
|
||||
.AddInMemoryCollection(new[]
|
||||
{ new KeyValuePair<string, string>("GalaxyRepository:ExtendedAttributes", "true") })
|
||||
.Build();
|
||||
|
||||
var config = new GalaxyRepositoryConfiguration();
|
||||
configuration.GetSection("GalaxyRepository").Bind(config);
|
||||
config.ExtendedAttributes.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the dashboard section binds operator-dashboard settings correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Dashboard_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Dashboard.Enabled.ShouldBe(true);
|
||||
config.Dashboard.Port.ShouldBe(8081);
|
||||
config.Dashboard.RefreshIntervalSeconds.ShouldBe(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the default configuration objects start with the expected bridge defaults.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
|
||||
config.OpcUa.Port.ShouldBe(4840);
|
||||
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
|
||||
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
|
||||
config.Dashboard.Enabled.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that BindAddress can be overridden to a specific hostname or IP.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUa_BindAddress_CanBeOverridden()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("OpcUa:BindAddress", "localhost")
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = new OpcUaConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(config);
|
||||
config.BindAddress.ShouldBe("localhost");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a valid configuration passes startup validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_ValidConfig_ReturnsTrue()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that an invalid OPC UA port is rejected by startup validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_InvalidPort_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.OpcUa.Port = 0;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that an empty Galaxy name is rejected because the bridge requires a namespace target.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_EmptyGalaxyName_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.OpcUa.GalaxyName = "";
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the Security section binds profile list from appsettings.json.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Security_Section_BindsProfilesCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Security.Profiles.ShouldContain("None");
|
||||
config.Security.AutoAcceptClientCertificates.ShouldBe(true);
|
||||
config.Security.MinimumCertificateKeySize.ShouldBe(2048);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stability review 2026-04-13 Finding 3: MxAccess.RequestTimeoutSeconds must be at
|
||||
/// least 1. Zero or negative values disable the safety bound and are rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_MxAccessRequestTimeoutZero_ReturnsFalse()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.MxAccess.RequestTimeoutSeconds = 0;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stability review 2026-04-13 Finding 3: Historian.RequestTimeoutSeconds must be at
|
||||
/// least 1 when historian is enabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_HistorianRequestTimeoutZero_ReturnsFalse()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Historian.Enabled = true;
|
||||
config.Historian.ServerName = "localhost";
|
||||
config.Historian.RequestTimeoutSeconds = 0;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the bound AppConfiguration carries non-zero default request timeouts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_DefaultRequestTimeouts_AreSensible()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.MxAccess.RequestTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
config.Historian.RequestTimeoutSeconds.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a minimum key size below 2048 is rejected by the validator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_InvalidMinKeySize_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Security.MinimumCertificateKeySize = 1024;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a valid configuration with security defaults passes validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validator_DefaultSecurityConfig_ReturnsTrue()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that custom security profiles can be bound from in-memory configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Security_Section_BindsCustomProfiles()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("Security:Profiles:0", "None"),
|
||||
new KeyValuePair<string, string>("Security:Profiles:1", "Basic256Sha256-SignAndEncrypt"),
|
||||
new KeyValuePair<string, string>("Security:AutoAcceptClientCertificates", "false"),
|
||||
new KeyValuePair<string, string>("Security:MinimumCertificateKeySize", "4096")
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Clear default list before binding to match production behavior
|
||||
var config = new AppConfiguration();
|
||||
config.Security.Profiles.Clear();
|
||||
configuration.GetSection("Security").Bind(config.Security);
|
||||
|
||||
config.Security.Profiles.Count.ShouldBe(2);
|
||||
config.Security.Profiles.ShouldContain("None");
|
||||
config.Security.Profiles.ShouldContain("Basic256Sha256-SignAndEncrypt");
|
||||
config.Security.AutoAcceptClientCertificates.ShouldBe(false);
|
||||
config.Security.MinimumCertificateKeySize.ShouldBe(4096);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redundancy_Section_BindsFromJson()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Redundancy.Enabled.ShouldBe(false);
|
||||
config.Redundancy.Mode.ShouldBe("Warm");
|
||||
config.Redundancy.Role.ShouldBe("Primary");
|
||||
config.Redundancy.ServiceLevelBase.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redundancy_Section_BindsCustomValues()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("Redundancy:Enabled", "true"),
|
||||
new KeyValuePair<string, string>("Redundancy:Mode", "Hot"),
|
||||
new KeyValuePair<string, string>("Redundancy:Role", "Secondary"),
|
||||
new KeyValuePair<string, string>("Redundancy:ServiceLevelBase", "180"),
|
||||
new KeyValuePair<string, string>("Redundancy:ServerUris:0", "urn:a"),
|
||||
new KeyValuePair<string, string>("Redundancy:ServerUris:1", "urn:b")
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = new AppConfiguration();
|
||||
configuration.GetSection("Redundancy").Bind(config.Redundancy);
|
||||
|
||||
config.Redundancy.Enabled.ShouldBe(true);
|
||||
config.Redundancy.Mode.ShouldBe("Hot");
|
||||
config.Redundancy.Role.ShouldBe("Secondary");
|
||||
config.Redundancy.ServiceLevelBase.ShouldBe(180);
|
||||
config.Redundancy.ServerUris.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_RedundancyEnabled_NoApplicationUri_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Redundancy.Enabled = true;
|
||||
config.Redundancy.ServerUris.Add("urn:a");
|
||||
config.Redundancy.ServerUris.Add("urn:b");
|
||||
// OpcUa.ApplicationUri is null
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_InvalidServiceLevelBase_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Redundancy.ServiceLevelBase = 0;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUa_ApplicationUri_DefaultsToNull()
|
||||
{
|
||||
var config = new OpcUaConfiguration();
|
||||
config.ApplicationUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUa_ApplicationUri_BindsFromConfig()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("OpcUa:ApplicationUri", "urn:test:app")
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = new OpcUaConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(config);
|
||||
config.ApplicationUri.ShouldBe("urn:test:app");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Historian_Section_BindsFromJson()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Historian.Enabled.ShouldBe(false);
|
||||
config.Historian.ServerName.ShouldBe("localhost");
|
||||
config.Historian.IntegratedSecurity.ShouldBe(true);
|
||||
config.Historian.Port.ShouldBe(32568);
|
||||
config.Historian.CommandTimeoutSeconds.ShouldBe(30);
|
||||
config.Historian.MaxValuesPerRead.ShouldBe(10000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Historian_Section_BindsCustomValues()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("Historian:Enabled", "true"),
|
||||
new KeyValuePair<string, string>("Historian:ServerName", "historian-server"),
|
||||
new KeyValuePair<string, string>("Historian:IntegratedSecurity", "false"),
|
||||
new KeyValuePair<string, string>("Historian:UserName", "testuser"),
|
||||
new KeyValuePair<string, string>("Historian:Password", "testpass"),
|
||||
new KeyValuePair<string, string>("Historian:Port", "12345"),
|
||||
new KeyValuePair<string, string>("Historian:CommandTimeoutSeconds", "60"),
|
||||
new KeyValuePair<string, string>("Historian:MaxValuesPerRead", "5000")
|
||||
})
|
||||
.Build();
|
||||
|
||||
var config = new HistorianConfiguration();
|
||||
configuration.GetSection("Historian").Bind(config);
|
||||
|
||||
config.Enabled.ShouldBe(true);
|
||||
config.ServerName.ShouldBe("historian-server");
|
||||
config.IntegratedSecurity.ShouldBe(false);
|
||||
config.UserName.ShouldBe("testuser");
|
||||
config.Password.ShouldBe("testpass");
|
||||
config.Port.ShouldBe(12345);
|
||||
config.CommandTimeoutSeconds.ShouldBe(60);
|
||||
config.MaxValuesPerRead.ShouldBe(5000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_HistorianEnabled_EmptyServerName_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Historian.Enabled = true;
|
||||
config.Historian.ServerName = "";
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_HistorianEnabled_InvalidPort_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Historian.Enabled = true;
|
||||
config.Historian.Port = 0;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_HistorianEnabled_NoIntegratedSecurity_EmptyUserName_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.Historian.Enabled = true;
|
||||
config.Historian.IntegratedSecurity = false;
|
||||
config.Historian.UserName = "";
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Configuration
|
||||
{
|
||||
public class HistorianConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConfig_Disabled()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.Enabled.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_ServerNameLocalhost()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.ServerName.ShouldBe("localhost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_IntegratedSecurityTrue()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.IntegratedSecurity.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_UserNameNull()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.UserName.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_PasswordNull()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.Password.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_Port32568()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.Port.ShouldBe(32568);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_CommandTimeout30()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.CommandTimeoutSeconds.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_MaxValuesPerRead10000()
|
||||
{
|
||||
var config = new HistorianConfiguration();
|
||||
config.MaxValuesPerRead.ShouldBe(10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Exhaustive coverage of the template-based alarm object filter's pattern parsing,
|
||||
/// chain matching, and hierarchy-subtree propagation logic.
|
||||
/// </summary>
|
||||
public class AlarmObjectFilterTests
|
||||
{
|
||||
// ---------- Pattern parsing & compilation ----------
|
||||
|
||||
[Fact]
|
||||
public void EmptyConfig_DisablesFilter()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(new AlarmFilterConfiguration());
|
||||
sut.Enabled.ShouldBeFalse();
|
||||
sut.PatternCount.ShouldBe(0);
|
||||
sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullConfig_DisablesFilter()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(null);
|
||||
sut.Enabled.ShouldBeFalse();
|
||||
sut.ResolveIncludedObjects(SingleObject()).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceEntries_AreSkipped()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("", " ", "\t"));
|
||||
sut.Enabled.ShouldBeFalse();
|
||||
sut.PatternCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaSeparatedEntry_SplitsIntoMultiplePatterns()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*, Pump_*"));
|
||||
sut.Enabled.ShouldBeTrue();
|
||||
sut.PatternCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaAndListForms_Combine()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("A*, B*", "C*"));
|
||||
sut.PatternCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhitespaceAroundCommas_IsTrimmed()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config(" TestMachine* , Pump_* "));
|
||||
sut.PatternCount.ShouldBe(2);
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Pump_A" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiteralPattern_MatchesExactTemplate()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine_001" }).ShouldBeFalse();
|
||||
sut.MatchesTemplateChain(new List<string> { "OtherMachine" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StarAlonePattern_MatchesAnyNonEmptyChain()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "Foo" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Bar", "Baz" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string>()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrefixWildcard_MatchesSuffix()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("*Machine"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "BigMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "MachineThing" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuffixWildcard_MatchesPrefix()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("Test*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestFoo" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Machine" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothWildcards_MatchesContains()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("*Machine*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachineWidget" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Machine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "Pump" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MiddleWildcard_MatchesWithInnerAnything()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("Test*Machine"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestCoolMachine" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachineX" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegexMetacharacters_AreEscapedLiterally()
|
||||
{
|
||||
// The '.' in Pump.v2 is a regex metachar; it must be a literal dot.
|
||||
var sut = new AlarmObjectFilter(Config("Pump.v2"));
|
||||
sut.MatchesTemplateChain(new List<string> { "Pump.v2" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "PumpXv2" }).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matching_IsCaseInsensitive()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("testmachine*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "TestMachine_001" }).ShouldBeTrue();
|
||||
sut.MatchesTemplateChain(new List<string> { "TESTMACHINE_XYZ" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GalaxyDollarPrefix_IsNormalizedAway_OnBothSides()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "$TestMachine" }).ShouldBeTrue();
|
||||
|
||||
var withDollarInPattern = new AlarmObjectFilter(Config("$TestMachine*"));
|
||||
withDollarInPattern.MatchesTemplateChain(new List<string> { "$TestMachine" }).ShouldBeTrue();
|
||||
withDollarInPattern.MatchesTemplateChain(new List<string> { "TestMachine" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------- Template-chain matching ----------
|
||||
|
||||
[Fact]
|
||||
public void ChainMatch_AtAncestorPosition_StillMatches()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var chain = new List<string> { "TestCoolMachine", "TestMachine", "$UserDefined" };
|
||||
sut.MatchesTemplateChain(chain).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChainNoMatch_ReturnsFalse()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
var chain = new List<string> { "FooBar", "$UserDefined" };
|
||||
sut.MatchesTemplateChain(chain).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyChain_NeverMatchesNonWildcard()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
sut.MatchesTemplateChain(new List<string>()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullChain_NeverMatches()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
sut.MatchesTemplateChain(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemTemplate_MatchesWhenOperatorOptsIn()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("Area*"));
|
||||
sut.MatchesTemplateChain(new List<string> { "$Area" }).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateChainEntries_StillMatch()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var chain = new List<string> { "TestMachine", "TestMachine", "$UserDefined" };
|
||||
sut.MatchesTemplateChain(chain).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------- Hierarchy subtree propagation ----------
|
||||
|
||||
[Fact]
|
||||
public void FlatHierarchy_OnlyMatchingIdsIncluded()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 0, template: "Pump"),
|
||||
Obj(3, parent: 0, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldContain(1);
|
||||
included.ShouldContain(3);
|
||||
included.ShouldNotContain(2);
|
||||
included.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchOnGrandparent_PropagatesToGrandchildren()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"), // root matches
|
||||
Obj(2, parent: 1, template: "UnrelatedThing"), // child — inherited
|
||||
Obj(3, parent: 2, template: "UnrelatedOtherThing") // grandchild — inherited
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBe(new[] { 1, 2, 3 }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrandchildMatch_DoesNotIncludeAncestors()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "Unrelated"),
|
||||
Obj(2, parent: 1, template: "Unrelated"),
|
||||
Obj(3, parent: 2, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBe(new[] { 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingMatches_StillSingleInclude()
|
||||
{
|
||||
// Grandparent matches AND grandchild matches independently — grandchild still counted once.
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 1, template: "Widget"),
|
||||
Obj(3, parent: 2, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.Count.ShouldBe(3);
|
||||
included.ShouldContain(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiblingSubtrees_OnlyMatchedSideIncluded()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"), // match — left subtree
|
||||
Obj(2, parent: 1, template: "Child"),
|
||||
Obj(10, parent: 0, template: "Pump"), // no match — right subtree
|
||||
Obj(11, parent: 10, template: "PumpChild")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBe(new[] { 1, 2 }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
// ---------- Defensive / edge cases ----------
|
||||
|
||||
[Fact]
|
||||
public void OrphanObject_TreatedAsRoot()
|
||||
{
|
||||
// Object 2 claims parent 99 which isn't in the hierarchy — still reached as a root.
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(2, parent: 99, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldContain(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyntheticCycle_TerminatesWithoutStackOverflow()
|
||||
{
|
||||
// A→B→A cycle defended by the visited set.
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 2, template: "TestMachine"),
|
||||
Obj(2, parent: 1, template: "Widget")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
// No object has a ParentGobjectId of 0, and each references an id that exists —
|
||||
// neither qualifies as a root under the orphan rule. Empty result is acceptable;
|
||||
// the critical assertion is that the call returns without crashing.
|
||||
var included = sut.ResolveIncludedObjects(hierarchy);
|
||||
included.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullTemplateChain_TreatedAsEmpty()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, ParentGobjectId = 0, TemplateChain = null! }
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyHierarchy_ReturnsEmptySet()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(new List<GalaxyObjectInfo>())!;
|
||||
included.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullHierarchy_ReturnsEmptySet()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine"));
|
||||
var included = sut.ResolveIncludedObjects(null)!;
|
||||
included.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleRoots_AllProcessed()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 0, template: "TestMachine"),
|
||||
Obj(3, parent: 0, template: "Pump")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
var included = sut.ResolveIncludedObjects(hierarchy)!;
|
||||
included.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---------- UnmatchedPatterns ----------
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_ListsPatternsWithZeroHits()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*", "NotThere*"));
|
||||
sut.ResolveIncludedObjects(hierarchy);
|
||||
sut.UnmatchedPatterns.ShouldContain("NotThere*");
|
||||
sut.UnmatchedPatterns.ShouldNotContain("TestMachine*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_EmptyWhenAllMatch()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, template: "TestMachine"),
|
||||
Obj(2, parent: 0, template: "Pump")
|
||||
};
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine", "Pump"));
|
||||
sut.ResolveIncludedObjects(hierarchy);
|
||||
sut.UnmatchedPatterns.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_EmptyWhenFilterDisabled()
|
||||
{
|
||||
var sut = new AlarmObjectFilter(new AlarmFilterConfiguration());
|
||||
sut.UnmatchedPatterns.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmatchedPatterns_ResetBetweenResolutions()
|
||||
{
|
||||
var hierarchyA = new List<GalaxyObjectInfo> { Obj(1, parent: 0, template: "TestMachine") };
|
||||
var hierarchyB = new List<GalaxyObjectInfo> { Obj(1, parent: 0, template: "Pump") };
|
||||
var sut = new AlarmObjectFilter(Config("TestMachine*"));
|
||||
|
||||
sut.ResolveIncludedObjects(hierarchyA);
|
||||
sut.UnmatchedPatterns.ShouldBeEmpty();
|
||||
|
||||
sut.ResolveIncludedObjects(hierarchyB);
|
||||
sut.UnmatchedPatterns.ShouldContain("TestMachine*");
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static AlarmFilterConfiguration Config(params string[] filters) =>
|
||||
new() { ObjectFilters = filters.ToList() };
|
||||
|
||||
private static GalaxyObjectInfo Obj(int id, int parent, string template) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
ParentGobjectId = parent,
|
||||
TagName = $"Obj_{id}",
|
||||
BrowseName = $"Obj_{id}",
|
||||
TemplateChain = new List<string> { template }
|
||||
};
|
||||
|
||||
private static List<GalaxyObjectInfo> SingleObject() => new()
|
||||
{
|
||||
Obj(1, parent: 0, template: "Anything")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies default and extended-field behavior for Galaxy attribute metadata objects.
|
||||
/// </summary>
|
||||
public class GalaxyAttributeInfoTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that a default attribute metadata object starts with empty strings for its text fields.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultValues_AreEmpty()
|
||||
{
|
||||
var info = new GalaxyAttributeInfo();
|
||||
info.PrimitiveName.ShouldBe("");
|
||||
info.AttributeSource.ShouldBe("");
|
||||
info.TagName.ShouldBe("");
|
||||
info.AttributeName.ShouldBe("");
|
||||
info.FullTagReference.ShouldBe("");
|
||||
info.DataTypeName.ShouldBe("");
|
||||
info.SecurityClassification.ShouldBe(1);
|
||||
info.IsHistorized.ShouldBeFalse();
|
||||
info.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that primitive-name and attribute-source fields can be populated for extended metadata rows.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExtendedFields_CanBeSet()
|
||||
{
|
||||
var info = new GalaxyAttributeInfo
|
||||
{
|
||||
PrimitiveName = "UDO",
|
||||
AttributeSource = "primitive"
|
||||
};
|
||||
info.PrimitiveName.ShouldBe("UDO");
|
||||
info.AttributeSource.ShouldBe("primitive");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that standard attribute rows leave the extended metadata fields empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StandardAttributes_HaveEmptyExtendedFields()
|
||||
{
|
||||
var info = new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 1,
|
||||
TagName = "TestObj",
|
||||
AttributeName = "MachineID",
|
||||
FullTagReference = "TestObj.MachineID",
|
||||
MxDataType = 5
|
||||
};
|
||||
info.PrimitiveName.ShouldBe("");
|
||||
info.AttributeSource.ShouldBe("");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies how Galaxy MX data types are mapped into OPC UA and CLR types by the bridge.
|
||||
/// </summary>
|
||||
public class MxDataTypeMapperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that known Galaxy MX data types map to the expected OPC UA data type node identifiers.
|
||||
/// </summary>
|
||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
||||
/// <param name="expectedNodeId">The expected OPC UA data type node identifier.</param>
|
||||
[Theory]
|
||||
[InlineData(1, 1u)] // Boolean
|
||||
[InlineData(2, 6u)] // Integer → Int32
|
||||
[InlineData(3, 10u)] // Float
|
||||
[InlineData(4, 11u)] // Double
|
||||
[InlineData(5, 12u)] // String
|
||||
[InlineData(6, 13u)] // DateTime
|
||||
[InlineData(7, 11u)] // ElapsedTime → Double
|
||||
[InlineData(8, 12u)] // Reference → String
|
||||
[InlineData(13, 6u)] // Enumeration → Int32
|
||||
[InlineData(14, 12u)] // Custom → String
|
||||
[InlineData(15, 21u)] // InternationalizedString → LocalizedText
|
||||
[InlineData(16, 12u)] // Custom → String
|
||||
public void MapToOpcUaDataType_AllKnownTypes(int mxDataType, uint expectedNodeId)
|
||||
{
|
||||
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(expectedNodeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown MX data types default to the OPC UA string data type.
|
||||
/// </summary>
|
||||
/// <param name="mxDataType">The unsupported MX data type code.</param>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(99)]
|
||||
[InlineData(-1)]
|
||||
public void MapToOpcUaDataType_UnknownDefaultsToString(int mxDataType)
|
||||
{
|
||||
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(12u); // String
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that known MX data types map to the expected CLR runtime types.
|
||||
/// </summary>
|
||||
/// <param name="mxDataType">The Galaxy MX data type code.</param>
|
||||
/// <param name="expectedType">The expected CLR type used by the bridge.</param>
|
||||
[Theory]
|
||||
[InlineData(1, typeof(bool))]
|
||||
[InlineData(2, typeof(int))]
|
||||
[InlineData(3, typeof(float))]
|
||||
[InlineData(4, typeof(double))]
|
||||
[InlineData(5, typeof(string))]
|
||||
[InlineData(6, typeof(DateTime))]
|
||||
[InlineData(7, typeof(double))]
|
||||
[InlineData(8, typeof(string))]
|
||||
[InlineData(13, typeof(int))]
|
||||
[InlineData(15, typeof(string))]
|
||||
public void MapToClrType_AllKnownTypes(int mxDataType, Type expectedType)
|
||||
{
|
||||
MxDataTypeMapper.MapToClrType(mxDataType).ShouldBe(expectedType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown MX data types default to the CLR string type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapToClrType_UnknownDefaultsToString()
|
||||
{
|
||||
MxDataTypeMapper.MapToClrType(999).ShouldBe(typeof(string));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the boolean MX type reports the expected OPC UA type name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetOpcUaTypeName_Boolean()
|
||||
{
|
||||
MxDataTypeMapper.GetOpcUaTypeName(1).ShouldBe("Boolean");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown MX types report the fallback OPC UA type name of string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetOpcUaTypeName_Unknown_ReturnsString()
|
||||
{
|
||||
MxDataTypeMapper.GetOpcUaTypeName(999).ShouldBe("String");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the operator-facing error messages and quality mappings derived from MXAccess error codes.
|
||||
/// </summary>
|
||||
public class MxErrorCodesTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that known MXAccess error codes produce readable operator-facing descriptions.
|
||||
/// </summary>
|
||||
/// <param name="code">The MXAccess error code.</param>
|
||||
/// <param name="expectedSubstring">A substring expected in the returned description.</param>
|
||||
[Theory]
|
||||
[InlineData(1008, "Invalid reference")]
|
||||
[InlineData(1012, "Wrong data type")]
|
||||
[InlineData(1013, "Not writable")]
|
||||
[InlineData(1014, "Request timed out")]
|
||||
[InlineData(1015, "Communication failure")]
|
||||
[InlineData(1016, "Not connected")]
|
||||
public void GetMessage_KnownCodes_ContainsDescription(int code, string expectedSubstring)
|
||||
{
|
||||
MxErrorCodes.GetMessage(code).ShouldContain(expectedSubstring);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown MXAccess error codes are reported as unknown while preserving the numeric code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetMessage_UnknownCode_ReturnsUnknown()
|
||||
{
|
||||
MxErrorCodes.GetMessage(9999).ShouldContain("Unknown");
|
||||
MxErrorCodes.GetMessage(9999).ShouldContain("9999");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that known MXAccess error codes map to the expected bridge quality values.
|
||||
/// </summary>
|
||||
/// <param name="code">The MXAccess error code.</param>
|
||||
/// <param name="expected">The expected bridge quality value.</param>
|
||||
[Theory]
|
||||
[InlineData(1008, Quality.BadConfigError)]
|
||||
[InlineData(1012, Quality.BadConfigError)]
|
||||
[InlineData(1013, Quality.BadOutOfService)]
|
||||
[InlineData(1014, Quality.BadCommFailure)]
|
||||
[InlineData(1015, Quality.BadCommFailure)]
|
||||
[InlineData(1016, Quality.BadNotConnected)]
|
||||
public void MapToQuality_KnownCodes(int code, Quality expected)
|
||||
{
|
||||
MxErrorCodes.MapToQuality(code).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown MXAccess error codes map to the generic bad quality bucket.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapToQuality_UnknownCode_ReturnsBad()
|
||||
{
|
||||
MxErrorCodes.MapToQuality(9999).ShouldBe(Quality.Bad);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the mapping between MXAccess quality codes, bridge quality values, and OPC UA status codes.
|
||||
/// </summary>
|
||||
public class QualityMapperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that bad-family MXAccess quality values map to the expected bridge quality values.
|
||||
/// </summary>
|
||||
/// <param name="input">The raw MXAccess quality code.</param>
|
||||
/// <param name="expected">The bridge quality value expected for the code.</param>
|
||||
[Theory]
|
||||
[InlineData(0, Quality.Bad)]
|
||||
[InlineData(4, Quality.BadConfigError)]
|
||||
[InlineData(20, Quality.BadCommFailure)]
|
||||
[InlineData(32, Quality.BadWaitingForInitialData)]
|
||||
public void MapFromMxAccess_BadFamily(int input, Quality expected)
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that uncertain-family MXAccess quality values map to the expected bridge quality values.
|
||||
/// </summary>
|
||||
/// <param name="input">The raw MXAccess quality code.</param>
|
||||
/// <param name="expected">The bridge quality value expected for the code.</param>
|
||||
[Theory]
|
||||
[InlineData(64, Quality.Uncertain)]
|
||||
[InlineData(68, Quality.UncertainLastUsable)]
|
||||
[InlineData(88, Quality.UncertainSubNormal)]
|
||||
public void MapFromMxAccess_UncertainFamily(int input, Quality expected)
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that good-family MXAccess quality values map to the expected bridge quality values.
|
||||
/// </summary>
|
||||
/// <param name="input">The raw MXAccess quality code.</param>
|
||||
/// <param name="expected">The bridge quality value expected for the code.</param>
|
||||
[Theory]
|
||||
[InlineData(192, Quality.Good)]
|
||||
[InlineData(216, Quality.GoodLocalOverride)]
|
||||
public void MapFromMxAccess_GoodFamily(int input, Quality expected)
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown bad-family values collapse to the generic bad quality bucket.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapFromMxAccess_UnknownBadValue_ReturnsBad()
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(63).ShouldBe(Quality.Bad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown uncertain-family values collapse to the generic uncertain quality bucket.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapFromMxAccess_UnknownUncertainValue_ReturnsUncertain()
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(100).ShouldBe(Quality.Uncertain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown good-family values collapse to the generic good quality bucket.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapFromMxAccess_UnknownGoodValue_ReturnsGood()
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(200).ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the generic good quality maps to the OPC UA good status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapToOpcUa_Good_Returns0()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.Good).ShouldBe(0x00000000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the generic bad quality maps to the OPC UA bad status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapToOpcUa_Bad_Returns80000000()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.Bad).ShouldBe(0x80000000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that communication failures map to the OPC UA bad communication-failure status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapToOpcUa_BadCommFailure()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.BadCommFailure).ShouldBe(0x80050000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the generic uncertain quality maps to the OPC UA uncertain status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapToOpcUa_Uncertain()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.Uncertain).ShouldBe(0x40000000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that good quality values are classified correctly by the quality extension helpers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QualityExtensions_IsGood()
|
||||
{
|
||||
Quality.Good.IsGood().ShouldBe(true);
|
||||
Quality.Good.IsBad().ShouldBe(false);
|
||||
Quality.Good.IsUncertain().ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that bad quality values are classified correctly by the quality extension helpers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QualityExtensions_IsBad()
|
||||
{
|
||||
Quality.Bad.IsBad().ShouldBe(true);
|
||||
Quality.Bad.IsGood().ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that uncertain quality values are classified correctly by the quality extension helpers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void QualityExtensions_IsUncertain()
|
||||
{
|
||||
Quality.Uncertain.IsUncertain().ShouldBe(true);
|
||||
Quality.Uncertain.IsGood().ShouldBe(false);
|
||||
Quality.Uncertain.IsBad().ShouldBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Domain
|
||||
{
|
||||
public class SecurityClassificationMapperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that Galaxy classifications intended for operator and engineering writes remain writable through OPC UA.
|
||||
/// </summary>
|
||||
/// <param name="classification">The Galaxy security classification value being evaluated for write access.</param>
|
||||
/// <param name="expected">The expected writable result for the supplied Galaxy classification.</param>
|
||||
[Theory]
|
||||
[InlineData(0, true)] // FreeAccess
|
||||
[InlineData(1, true)] // Operate
|
||||
[InlineData(4, true)] // Tune
|
||||
[InlineData(5, true)] // Configure
|
||||
public void Writable_SecurityLevels(int classification, bool expected)
|
||||
{
|
||||
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that secured or view-only Galaxy classifications are exposed as read-only attributes.
|
||||
/// </summary>
|
||||
/// <param name="classification">The Galaxy security classification value expected to block writes.</param>
|
||||
/// <param name="expected">The expected writable result for the supplied read-only Galaxy classification.</param>
|
||||
[Theory]
|
||||
[InlineData(2, false)] // SecuredWrite
|
||||
[InlineData(3, false)] // VerifiedWrite
|
||||
[InlineData(6, false)] // ViewOnly
|
||||
public void ReadOnly_SecurityLevels(int classification, bool expected)
|
||||
{
|
||||
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that unknown security classifications do not accidentally block writes for unmapped Galaxy values.
|
||||
/// </summary>
|
||||
/// <param name="classification">
|
||||
/// An unmapped Galaxy security classification value that should fall back to writable
|
||||
/// behavior.
|
||||
/// </param>
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(7)]
|
||||
[InlineData(99)]
|
||||
public void Unknown_Values_DefaultToWritable(int classification)
|
||||
{
|
||||
SecurityClassificationMapper.IsWritable(classification).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.EndToEnd
|
||||
{
|
||||
/// <summary>
|
||||
/// THE ULTIMATE SMOKE TEST: Full service with fakes, verifying the complete data flow.
|
||||
/// (1) Address space built, (2) MXAccess data change → callback, (3) read → correct tag ref,
|
||||
/// (4) write → correct tag+value, (5) dashboard has real data.
|
||||
/// </summary>
|
||||
public class FullDataFlowTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the fake-backed bridge can start, build the address space, and expose coherent status data end to
|
||||
/// end.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FullDataFlow_EndToEnd()
|
||||
{
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration { Port = 14842, GalaxyName = "TestGalaxy", EndpointPath = "/LmxOpcUa" },
|
||||
MxAccess = new MxAccessConfiguration
|
||||
{ ClientName = "Test", ReadTimeoutSeconds = 2, WriteTimeoutSeconds = 2 },
|
||||
GalaxyRepository = new GalaxyRepositoryConfiguration { ChangeDetectionIntervalSeconds = 60 },
|
||||
Dashboard = new DashboardConfiguration { Enabled = false }
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy();
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, IsArea = true },
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 1,
|
||||
IsArea = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver",
|
||||
BrowseName = "DelmiaReceiver", ParentGobjectId = 2, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "TestMachine_001", AttributeName = "MachineID",
|
||||
FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath",
|
||||
FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber",
|
||||
FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
service.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// (1) OPC UA server host created
|
||||
service.ServerHost.ShouldNotBeNull();
|
||||
|
||||
// (2) MXAccess connected and proxy registered
|
||||
proxy.IsRegistered.ShouldBe(true);
|
||||
service.MxClient.ShouldNotBeNull();
|
||||
service.MxClient!.State.ShouldBe(ConnectionState.Connected);
|
||||
|
||||
// (3) Address space model can be built from the same data
|
||||
var model = AddressSpaceBuilder.Build(repo.Hierarchy, repo.Attributes);
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true);
|
||||
model.VariableCount.ShouldBe(3);
|
||||
model.ObjectCount.ShouldBe(2); // TestMachine + DelmiaReceiver (DEV is area)
|
||||
|
||||
// (4) Tag reference resolves correctly for read/write
|
||||
var tagRef = model.NodeIdToTagReference["DelmiaReceiver_001.DownloadPath"];
|
||||
tagRef.ShouldBe("DelmiaReceiver_001.DownloadPath");
|
||||
|
||||
// (5) Galaxy stats have real data
|
||||
service.GalaxyStatsInstance.ShouldNotBeNull();
|
||||
service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy");
|
||||
service.GalaxyStatsInstance.DbConnected.ShouldBe(true);
|
||||
service.GalaxyStatsInstance.ObjectCount.ShouldBe(3);
|
||||
service.GalaxyStatsInstance.AttributeCount.ShouldBe(3);
|
||||
|
||||
// (5b) Status report has real data
|
||||
service.StatusReportInstance.ShouldNotBeNull();
|
||||
var html = service.StatusReportInstance!.GenerateHtml();
|
||||
html.ShouldContain("TestGalaxy");
|
||||
html.ShouldContain("Connected");
|
||||
|
||||
var json = service.StatusReportInstance.GenerateJson();
|
||||
json.ShouldContain("TestGalaxy");
|
||||
|
||||
service.StatusReportInstance.IsHealthy().ShouldBe(true);
|
||||
|
||||
// Verify change detection is wired
|
||||
service.ChangeDetectionInstance.ShouldNotBeNull();
|
||||
|
||||
// Verify metrics created
|
||||
service.Metrics.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
service.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.GalaxyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the polling service that detects Galaxy deploy changes and triggers address-space rebuilds.
|
||||
/// </summary>
|
||||
public class ChangeDetectionServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the first poll always triggers an initial rebuild notification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FirstPoll_AlwaysTriggers()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, 1);
|
||||
var triggered = false;
|
||||
service.OnGalaxyChanged += () => triggered = true;
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500);
|
||||
service.Stop();
|
||||
|
||||
triggered.ShouldBe(true);
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that repeated polls with the same deploy timestamp do not retrigger rebuilds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SameTimestamp_DoesNotTriggerAgain()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, 1);
|
||||
var triggerCount = 0;
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(2500); // Should have polled at least twice
|
||||
service.Stop();
|
||||
|
||||
triggerCount.ShouldBe(1); // Only the first poll
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a changed deploy timestamp triggers another rebuild notification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChangedTimestamp_TriggersAgain()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, 1);
|
||||
var triggerCount = 0;
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Change the deploy time
|
||||
repo.LastDeployTime = new DateTime(2024, 2, 1);
|
||||
await Task.Delay(1500);
|
||||
service.Stop();
|
||||
|
||||
triggerCount.ShouldBeGreaterThanOrEqualTo(2);
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that transient polling failures do not crash the service and allow later recovery.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FailedPoll_DoesNotCrash_RetriesNext()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, 1);
|
||||
var triggerCount = 0;
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Make it fail
|
||||
repo.ShouldThrow = true;
|
||||
await Task.Delay(1500);
|
||||
|
||||
// Restore and it should recover
|
||||
repo.ShouldThrow = false;
|
||||
repo.LastDeployTime = new DateTime(2024, 3, 1);
|
||||
await Task.Delay(1500);
|
||||
service.Stop();
|
||||
|
||||
// Should have triggered at least on first poll and on the changed timestamp
|
||||
triggerCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that stopping the service before it starts is a harmless no-op.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stop_BeforeStart_DoesNotThrow()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository();
|
||||
var service = new ChangeDetectionService(repo, 30);
|
||||
service.Stop(); // Should not throw
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.GalaxyRepository
|
||||
{
|
||||
public class PlatformScopeFilterTests
|
||||
{
|
||||
// Category constants matching the Galaxy schema.
|
||||
private const int CatPlatform = 1;
|
||||
private const int CatAppEngine = 3;
|
||||
private const int CatUserDefined = 10;
|
||||
private const int CatArea = 13;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a two-platform Galaxy hierarchy for filtering tests.
|
||||
///
|
||||
/// Structure:
|
||||
/// Area1 (id=1, area, parent=0)
|
||||
/// PlatformA (id=10, cat=1, hosted_by=0) ← node "NODEA"
|
||||
/// EngineA (id=20, cat=3, hosted_by=10)
|
||||
/// Obj1 (id=30, cat=10, hosted_by=20)
|
||||
/// Obj2 (id=31, cat=10, hosted_by=20)
|
||||
/// PlatformB (id=11, cat=1, hosted_by=0) ← node "NODEB"
|
||||
/// EngineB (id=21, cat=3, hosted_by=11)
|
||||
/// Obj3 (id=32, cat=10, hosted_by=21)
|
||||
/// Area2 (id=2, area, parent=0)
|
||||
/// Obj4 (id=33, cat=10, hosted_by=21) ← hosted by EngineB
|
||||
/// </summary>
|
||||
private static (List<GalaxyObjectInfo> hierarchy, List<PlatformInfo> platforms) CreateTwoPlatformGalaxy()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 10, TagName = "PlatformA", ContainedName = "PlatformA", BrowseName = "PlatformA", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 20, TagName = "EngineA_001", ContainedName = "EngineA", BrowseName = "EngineA", ParentGobjectId = 10, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 10 },
|
||||
new() { GobjectId = 30, TagName = "Obj1_001", ContainedName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 },
|
||||
new() { GobjectId = 31, TagName = "Obj2_001", ContainedName = "Obj2", BrowseName = "Obj2", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 },
|
||||
new() { GobjectId = 11, TagName = "PlatformB", ContainedName = "PlatformB", BrowseName = "PlatformB", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 21, TagName = "EngineB_001", ContainedName = "EngineB", BrowseName = "EngineB", ParentGobjectId = 11, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 11 },
|
||||
new() { GobjectId = 32, TagName = "Obj3_001", ContainedName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 21, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 21 },
|
||||
new() { GobjectId = 2, TagName = "Area2", ContainedName = "Area2", BrowseName = "Area2", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 33, TagName = "Obj4_001", ContainedName = "Obj4", BrowseName = "Obj4", ParentGobjectId = 2, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 21 },
|
||||
};
|
||||
|
||||
var platforms = new List<PlatformInfo>
|
||||
{
|
||||
new() { GobjectId = 10, NodeName = "NODEA" },
|
||||
new() { GobjectId = 11, NodeName = "NODEB" },
|
||||
};
|
||||
|
||||
return (hierarchy, platforms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_ReturnsOnlyObjectsUnderMatchingPlatform()
|
||||
{
|
||||
var (hierarchy, platforms) = CreateTwoPlatformGalaxy();
|
||||
|
||||
var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEA");
|
||||
|
||||
// Should include: Area1, PlatformA, EngineA, Obj1, Obj2
|
||||
// Should exclude: PlatformB, EngineB, Obj3, Area2, Obj4
|
||||
ids.ShouldContain(1); // Area1 (ancestor of PlatformA)
|
||||
ids.ShouldContain(10); // PlatformA
|
||||
ids.ShouldContain(20); // EngineA
|
||||
ids.ShouldContain(30); // Obj1
|
||||
ids.ShouldContain(31); // Obj2
|
||||
ids.ShouldNotContain(11); // PlatformB
|
||||
ids.ShouldNotContain(21); // EngineB
|
||||
ids.ShouldNotContain(32); // Obj3
|
||||
ids.ShouldNotContain(33); // Obj4
|
||||
ids.ShouldNotContain(2); // Area2 (no local children)
|
||||
filtered.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_ReturnsObjectsUnderPlatformB()
|
||||
{
|
||||
var (hierarchy, platforms) = CreateTwoPlatformGalaxy();
|
||||
|
||||
var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEB");
|
||||
|
||||
// Should include: Area1, PlatformB, EngineB, Obj3, Area2, Obj4
|
||||
ids.ShouldContain(1); // Area1 (ancestor of PlatformB)
|
||||
ids.ShouldContain(11); // PlatformB
|
||||
ids.ShouldContain(21); // EngineB
|
||||
ids.ShouldContain(32); // Obj3
|
||||
ids.ShouldContain(2); // Area2 (has Obj4 hosted by EngineB)
|
||||
ids.ShouldContain(33); // Obj4
|
||||
// Should exclude PlatformA's subtree
|
||||
ids.ShouldNotContain(10);
|
||||
ids.ShouldNotContain(20);
|
||||
ids.ShouldNotContain(30);
|
||||
ids.ShouldNotContain(31);
|
||||
filtered.Count.ShouldBe(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_IsCaseInsensitiveOnNodeName()
|
||||
{
|
||||
var (hierarchy, platforms) = CreateTwoPlatformGalaxy();
|
||||
|
||||
var (filtered, _) = PlatformScopeFilter.Filter(hierarchy, platforms, "nodea");
|
||||
|
||||
filtered.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_ReturnsEmptyWhenNoMatchingPlatform()
|
||||
{
|
||||
var (hierarchy, platforms) = CreateTwoPlatformGalaxy();
|
||||
|
||||
var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "UNKNOWN");
|
||||
|
||||
filtered.ShouldBeEmpty();
|
||||
ids.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_IncludesAncestorAreasForConnectedTree()
|
||||
{
|
||||
// An object nested several levels deep should pull in all ancestor areas.
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "TopArea", ContainedName = "TopArea", BrowseName = "TopArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 2, TagName = "SubArea", ContainedName = "SubArea", BrowseName = "SubArea", ParentGobjectId = 1, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 10, TagName = "Plat", ContainedName = "Plat", BrowseName = "Plat", ParentGobjectId = 2, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 20, TagName = "Eng", ContainedName = "Eng", BrowseName = "Eng", ParentGobjectId = 10, IsArea = false, CategoryId = CatAppEngine, HostedByGobjectId = 10 },
|
||||
new() { GobjectId = 30, TagName = "Obj", ContainedName = "Obj", BrowseName = "Obj", ParentGobjectId = 20, IsArea = false, CategoryId = CatUserDefined, HostedByGobjectId = 20 },
|
||||
};
|
||||
var platforms = new List<PlatformInfo> { new() { GobjectId = 10, NodeName = "LOCAL" } };
|
||||
|
||||
var (filtered, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "LOCAL");
|
||||
|
||||
ids.ShouldContain(1); // TopArea
|
||||
ids.ShouldContain(2); // SubArea
|
||||
ids.ShouldContain(10); // Platform
|
||||
ids.ShouldContain(20); // Engine
|
||||
ids.ShouldContain(30); // Object
|
||||
filtered.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_ExcludesAreaWithNoLocalDescendants()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "UsedArea", ContainedName = "UsedArea", BrowseName = "UsedArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 2, TagName = "EmptyArea", ContainedName = "EmptyArea", BrowseName = "EmptyArea", ParentGobjectId = 0, IsArea = true, CategoryId = CatArea, HostedByGobjectId = 0 },
|
||||
new() { GobjectId = 10, TagName = "Plat", ContainedName = "Plat", BrowseName = "Plat", ParentGobjectId = 1, IsArea = false, CategoryId = CatPlatform, HostedByGobjectId = 0 },
|
||||
};
|
||||
var platforms = new List<PlatformInfo> { new() { GobjectId = 10, NodeName = "LOCAL" } };
|
||||
|
||||
var (_, ids) = PlatformScopeFilter.Filter(hierarchy, platforms, "LOCAL");
|
||||
|
||||
ids.ShouldContain(1); // UsedArea (ancestor of Plat)
|
||||
ids.ShouldNotContain(2); // EmptyArea (no local descendants)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterAttributes_RetainsOnlyMatchingGobjectIds()
|
||||
{
|
||||
var gobjectIds = new HashSet<int> { 10, 30 };
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new() { GobjectId = 10, TagName = "Plat", AttributeName = "Attr1", FullTagReference = "Plat.Attr1" },
|
||||
new() { GobjectId = 20, TagName = "Other", AttributeName = "Attr2", FullTagReference = "Other.Attr2" },
|
||||
new() { GobjectId = 30, TagName = "Obj", AttributeName = "Attr3", FullTagReference = "Obj.Attr3" },
|
||||
};
|
||||
|
||||
var filtered = PlatformScopeFilter.FilterAttributes(attributes, gobjectIds);
|
||||
|
||||
filtered.Count.ShouldBe(2);
|
||||
filtered.ShouldAllBe(a => gobjectIds.Contains(a.GobjectId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_PreservesOriginalOrder()
|
||||
{
|
||||
var (hierarchy, platforms) = CreateTwoPlatformGalaxy();
|
||||
|
||||
var (filtered, _) = PlatformScopeFilter.Filter(hierarchy, platforms, "NODEA");
|
||||
|
||||
// Verify the order matches the original hierarchy order for included items.
|
||||
for (int i = 1; i < filtered.Count; i++)
|
||||
{
|
||||
var prevIndex = hierarchy.FindIndex(o => o.GobjectId == filtered[i - 1].GobjectId);
|
||||
var currIndex = hierarchy.FindIndex(o => o.GobjectId == filtered[i].GobjectId);
|
||||
prevIndex.ShouldBeLessThan(currIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic authentication provider for integration tests.
|
||||
/// Validates credentials against hardcoded username/password pairs
|
||||
/// and returns configured role sets per user.
|
||||
/// </summary>
|
||||
internal class FakeAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
|
||||
{
|
||||
private readonly Dictionary<string, string> _credentials = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Dictionary<string, IReadOnlyList<string>> _roles = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyList<string> GetUserRoles(string username)
|
||||
{
|
||||
return _roles.TryGetValue(username, out var roles) ? roles : new[] { AppRoles.ReadOnly };
|
||||
}
|
||||
|
||||
public bool ValidateCredentials(string username, string password)
|
||||
{
|
||||
return _credentials.TryGetValue(username, out var expected) && expected == password;
|
||||
}
|
||||
|
||||
public FakeAuthenticationProvider AddUser(string username, string password, params string[] roles)
|
||||
{
|
||||
_credentials[username] = password;
|
||||
_roles[username] = roles;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory Galaxy repository used by tests to control hierarchy rows, attribute rows, and deploy metadata without
|
||||
/// SQL access.
|
||||
/// </summary>
|
||||
public class FakeGalaxyRepository : IGalaxyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the hierarchy rows returned to address-space construction logic.
|
||||
/// </summary>
|
||||
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the attribute rows returned to address-space construction logic.
|
||||
/// </summary>
|
||||
public List<GalaxyAttributeInfo> Attributes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the deploy timestamp returned to change-detection logic.
|
||||
/// </summary>
|
||||
public DateTime? LastDeployTime { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether connection checks should report success.
|
||||
/// </summary>
|
||||
public bool ConnectionSucceeds { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether repository calls should throw to simulate database failures.
|
||||
/// </summary>
|
||||
public bool ShouldThrow { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the fake repository simulates a Galaxy deploy change.
|
||||
/// </summary>
|
||||
public event Action? OnGalaxyChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured hierarchy rows or throws to simulate a repository failure.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
|
||||
/// <returns>The configured hierarchy rows.</returns>
|
||||
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(Hierarchy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured attribute rows or throws to simulate a repository failure.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
|
||||
/// <returns>The configured attribute rows.</returns>
|
||||
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(Attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured deploy timestamp or throws to simulate a repository failure.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
|
||||
/// <returns>The configured deploy timestamp.</returns>
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(LastDeployTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configured connection result or throws to simulate a repository failure.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token ignored by the in-memory fake.</param>
|
||||
/// <returns>The configured connection result.</returns>
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(ConnectionSucceeds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the deploy-change event so tests can trigger rebuild logic.
|
||||
/// </summary>
|
||||
public void RaiseGalaxyChanged()
|
||||
{
|
||||
OnGalaxyChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory IMxAccessClient used by tests to drive connection, read, write, and subscription scenarios without COM
|
||||
/// runtime dependencies.
|
||||
/// </summary>
|
||||
public class FakeMxAccessClient : IMxAccessClient
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _subscriptions =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the in-memory tag-value table returned by fake reads.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, Vtq> TagValues { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the values written through the fake client so tests can assert write behavior.
|
||||
/// </summary>
|
||||
public List<(string Tag, object Value)> WrittenValues { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the result returned by fake writes to simulate success or failure.
|
||||
/// </summary>
|
||||
public bool WriteResult { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the connection state returned to the system under test.
|
||||
/// </summary>
|
||||
public ConnectionState State { get; set; } = ConnectionState.Connected;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of active subscriptions currently stored by the fake client.
|
||||
/// </summary>
|
||||
public int ActiveSubscriptionCount => _subscriptions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the reconnect count exposed to health and dashboard tests.
|
||||
/// </summary>
|
||||
public int ReconnectCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, <see cref="SubscribeAsync"/> returns a faulted task with this exception.
|
||||
/// </summary>
|
||||
public Exception? SubscribeException { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, <see cref="UnsubscribeAsync"/> returns a faulted task with this exception.
|
||||
/// </summary>
|
||||
public Exception? UnsubscribeException { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, <see cref="ReadAsync"/> returns a faulted task with this exception.
|
||||
/// </summary>
|
||||
public Exception? ReadException { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, <see cref="WriteAsync"/> returns a faulted task with this exception.
|
||||
/// </summary>
|
||||
public Exception? WriteException { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when tests explicitly simulate a connection-state transition.
|
||||
/// </summary>
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when tests publish a simulated runtime value change.
|
||||
/// </summary>
|
||||
public event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Simulates establishing a healthy runtime connection.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||
public Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
State = ConnectionState.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates disconnecting from the runtime.
|
||||
/// </summary>
|
||||
public Task DisconnectAsync()
|
||||
{
|
||||
State = ConnectionState.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores a subscription callback so later simulated data changes can target it.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The Galaxy attribute reference to monitor.</param>
|
||||
/// <param name="callback">The callback that should receive simulated value changes.</param>
|
||||
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
||||
{
|
||||
if (SubscribeException != null)
|
||||
return Task.FromException(SubscribeException);
|
||||
_subscriptions[fullTagReference] = callback;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a stored subscription callback for the specified tag reference.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The Galaxy attribute reference to stop monitoring.</param>
|
||||
public Task UnsubscribeAsync(string fullTagReference)
|
||||
{
|
||||
if (UnsubscribeException != null)
|
||||
return Task.FromException(UnsubscribeException);
|
||||
_subscriptions.TryRemove(fullTagReference, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current in-memory VTQ for a tag reference or a bad-quality placeholder when none has been seeded.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The Galaxy attribute reference to read.</param>
|
||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||
/// <returns>The seeded VTQ value or a bad not-connected VTQ when the tag was not populated.</returns>
|
||||
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
|
||||
{
|
||||
if (ReadException != null)
|
||||
return Task.FromException<Vtq>(ReadException);
|
||||
if (TagValues.TryGetValue(fullTagReference, out var vtq))
|
||||
return Task.FromResult(vtq);
|
||||
return Task.FromResult(Vtq.Bad(Quality.BadNotConnected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a write request, optionally updates the in-memory tag table, and returns the configured write result.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The Galaxy attribute reference being written.</param>
|
||||
/// <param name="value">The value supplied by the code under test.</param>
|
||||
/// <param name="ct">A cancellation token that is ignored by the in-memory fake.</param>
|
||||
/// <returns>A completed task returning the configured write outcome.</returns>
|
||||
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
|
||||
{
|
||||
if (WriteException != null)
|
||||
return Task.FromException<bool>(WriteException);
|
||||
WrittenValues.Add((fullTagReference, value));
|
||||
if (WriteResult)
|
||||
TagValues[fullTagReference] = Vtq.Good(value);
|
||||
return Task.FromResult(WriteResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the fake client. No unmanaged resources are held.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a simulated tag-value change to both the event stream and any stored subscription callback.
|
||||
/// </summary>
|
||||
/// <param name="address">The Galaxy attribute reference whose value changed.</param>
|
||||
/// <param name="vtq">The value, timestamp, and quality payload to publish.</param>
|
||||
public void SimulateDataChange(string address, Vtq vtq)
|
||||
{
|
||||
OnTagValueChanged?.Invoke(address, vtq);
|
||||
if (_subscriptions.TryGetValue(address, out var callback))
|
||||
callback(address, vtq);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises a simulated connection-state transition for health and reconnect tests.
|
||||
/// </summary>
|
||||
/// <param name="prev">The previous connection state.</param>
|
||||
/// <param name="curr">The new connection state.</param>
|
||||
public void RaiseConnectionStateChanged(ConnectionState prev, ConnectionState curr)
|
||||
{
|
||||
State = curr;
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(prev, curr));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using ArchestrA.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake IMxProxy for testing without the MxAccess COM runtime.
|
||||
/// Simulates connections, subscriptions, data changes, and writes.
|
||||
/// </summary>
|
||||
public class FakeMxProxy : IMxProxy
|
||||
{
|
||||
private int _connectionHandle;
|
||||
private int _nextHandle = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item-handle to tag-reference map built by the test as attributes are registered with the fake runtime.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<int, string> Items { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item handles currently marked as advised so tests can assert subscription behavior.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<int, bool> AdvisedItems { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the values written through the fake runtime so write scenarios can assert the final payload.
|
||||
/// </summary>
|
||||
public List<(string Address, object Value)> WrittenValues { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the fake runtime is currently considered registered.
|
||||
/// </summary>
|
||||
public bool IsRegistered { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of times the system under test attempted to register with the fake runtime.
|
||||
/// </summary>
|
||||
public int RegisterCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of times the system under test attempted to unregister from the fake runtime.
|
||||
/// </summary>
|
||||
public int UnregisterCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether registration should fail to exercise connection-error paths.
|
||||
/// </summary>
|
||||
public bool ShouldFailRegister { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether writes should fail to exercise runtime write-error paths.
|
||||
/// </summary>
|
||||
public bool ShouldFailWrite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the fake should suppress the write-complete callback for timeout scenarios.
|
||||
/// </summary>
|
||||
public bool SkipWriteCompleteCallback { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the status code returned in the simulated write-complete callback.
|
||||
/// </summary>
|
||||
public int WriteCompleteStatus { get; set; } = 0; // 0 = success
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the fake proxy publishes a simulated runtime data-change callback to the system under test.
|
||||
/// </summary>
|
||||
public event MxDataChangeHandler? OnDataChange;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the fake proxy publishes a simulated write-complete callback to the system under test.
|
||||
/// </summary>
|
||||
public event MxWriteCompleteHandler? OnWriteComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Simulates the MXAccess registration handshake and returns a synthetic connection handle.
|
||||
/// </summary>
|
||||
/// <param name="clientName">The client name supplied by the code under test.</param>
|
||||
/// <returns>A synthetic connection handle for subsequent fake operations.</returns>
|
||||
public int Register(string clientName)
|
||||
{
|
||||
RegisterCallCount++;
|
||||
if (ShouldFailRegister) throw new InvalidOperationException("Register failed (simulated)");
|
||||
IsRegistered = true;
|
||||
_connectionHandle = Interlocked.Increment(ref _nextHandle);
|
||||
return _connectionHandle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates tearing down the fake MXAccess connection.
|
||||
/// </summary>
|
||||
/// <param name="handle">The connection handle supplied by the code under test.</param>
|
||||
public void Unregister(int handle)
|
||||
{
|
||||
UnregisterCallCount++;
|
||||
IsRegistered = false;
|
||||
_connectionHandle = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates resolving a tag reference into a fake runtime item handle.
|
||||
/// </summary>
|
||||
/// <param name="handle">The synthetic connection handle.</param>
|
||||
/// <param name="address">The Galaxy attribute reference being registered.</param>
|
||||
/// <returns>A synthetic item handle.</returns>
|
||||
public int AddItem(int handle, string address)
|
||||
{
|
||||
var itemHandle = Interlocked.Increment(ref _nextHandle);
|
||||
Items[itemHandle] = address;
|
||||
return itemHandle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates removing an item from the fake runtime session.
|
||||
/// </summary>
|
||||
/// <param name="handle">The synthetic connection handle.</param>
|
||||
/// <param name="itemHandle">The synthetic item handle to remove.</param>
|
||||
public void RemoveItem(int handle, int itemHandle)
|
||||
{
|
||||
Items.TryRemove(itemHandle, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as actively advised so tests can assert subscription activation.
|
||||
/// </summary>
|
||||
/// <param name="handle">The synthetic connection handle.</param>
|
||||
/// <param name="itemHandle">The synthetic item handle being monitored.</param>
|
||||
public void AdviseSupervisory(int handle, int itemHandle)
|
||||
{
|
||||
AdvisedItems[itemHandle] = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as no longer advised so tests can assert subscription teardown.
|
||||
/// </summary>
|
||||
/// <param name="handle">The synthetic connection handle.</param>
|
||||
/// <param name="itemHandle">The synthetic item handle no longer being monitored.</param>
|
||||
public void UnAdviseSupervisory(int handle, int itemHandle)
|
||||
{
|
||||
AdvisedItems.TryRemove(itemHandle, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a runtime write, records the written value, and optionally raises the write-complete callback.
|
||||
/// </summary>
|
||||
/// <param name="handle">The synthetic connection handle.</param>
|
||||
/// <param name="itemHandle">The synthetic item handle to write.</param>
|
||||
/// <param name="value">The value supplied by the system under test.</param>
|
||||
/// <param name="securityClassification">The security classification supplied with the write request.</param>
|
||||
public void Write(int handle, int itemHandle, object value, int securityClassification)
|
||||
{
|
||||
if (ShouldFailWrite) throw new InvalidOperationException("Write failed (simulated)");
|
||||
|
||||
if (Items.TryGetValue(itemHandle, out var address))
|
||||
WrittenValues.Add((address, value));
|
||||
|
||||
// Simulate async write complete callback
|
||||
var status = new MXSTATUS_PROXY[1];
|
||||
if (WriteCompleteStatus == 0)
|
||||
{
|
||||
status[0].success = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
status[0].success = 0;
|
||||
status[0].detail = (short)WriteCompleteStatus;
|
||||
}
|
||||
|
||||
if (!SkipWriteCompleteCallback)
|
||||
OnWriteComplete?.Invoke(_connectionHandle, itemHandle, ref status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates an MXAccess data change event for a specific item handle.
|
||||
/// </summary>
|
||||
/// <param name="itemHandle">The synthetic item handle that should receive the new value.</param>
|
||||
/// <param name="value">The value to publish to the system under test.</param>
|
||||
/// <param name="quality">The runtime quality code to send with the value.</param>
|
||||
/// <param name="timestamp">The optional timestamp to send with the value; defaults to the current UTC time.</param>
|
||||
public void SimulateDataChange(int itemHandle, object value, int quality = 192, DateTime? timestamp = null)
|
||||
{
|
||||
var status = new MXSTATUS_PROXY[1];
|
||||
status[0].success = 1;
|
||||
OnDataChange?.Invoke(_connectionHandle, itemHandle, value, quality,
|
||||
timestamp ?? DateTime.UtcNow, ref status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates data change for a specific address (finds handle by address).
|
||||
/// </summary>
|
||||
/// <param name="address">The Galaxy attribute reference whose registered handle should receive the new value.</param>
|
||||
/// <param name="value">The value to publish to the system under test.</param>
|
||||
/// <param name="quality">The runtime quality code to send with the value.</param>
|
||||
/// <param name="timestamp">The optional timestamp to send with the value; defaults to the current UTC time.</param>
|
||||
public void SimulateDataChangeByAddress(string address, object value, int quality = 192,
|
||||
DateTime? timestamp = null)
|
||||
{
|
||||
foreach (var kvp in Items)
|
||||
if (string.Equals(kvp.Value, address, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SimulateDataChange(kvp.Key, value, quality, timestamp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// xUnit fixture that manages an OpcUaService lifecycle with automatic port allocation.
|
||||
/// Guarantees no port conflicts between parallel tests.
|
||||
/// Usage (per-test):
|
||||
/// var fixture = OpcUaServerFixture.WithFakes();
|
||||
/// await fixture.InitializeAsync();
|
||||
/// try { ... } finally { await fixture.DisposeAsync(); }
|
||||
/// Usage (skip COM entirely):
|
||||
/// var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
/// </summary>
|
||||
internal class OpcUaServerFixture : IAsyncLifetime
|
||||
{
|
||||
private static int _nextPort = 16000;
|
||||
|
||||
private readonly OpcUaServiceBuilder _builder;
|
||||
private bool _started;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a fixture around a prepared service builder and optional fake dependencies.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder used to construct the service under test.</param>
|
||||
/// <param name="repo">The optional fake Galaxy repository exposed to tests.</param>
|
||||
/// <param name="mxClient">The optional fake MXAccess client exposed to tests.</param>
|
||||
/// <param name="mxProxy">The optional fake MXAccess proxy exposed to tests.</param>
|
||||
private OpcUaServerFixture(OpcUaServiceBuilder builder,
|
||||
FakeGalaxyRepository? repo = null,
|
||||
FakeMxAccessClient? mxClient = null,
|
||||
FakeMxProxy? mxProxy = null)
|
||||
{
|
||||
OpcUaPort = Interlocked.Increment(ref _nextPort);
|
||||
_builder = builder;
|
||||
_builder.WithOpcUaPort(OpcUaPort);
|
||||
_builder.DisableDashboard();
|
||||
GalaxyRepository = repo;
|
||||
MxAccessClient = mxClient;
|
||||
MxProxy = mxProxy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the started service instance managed by the fixture.
|
||||
/// </summary>
|
||||
public OpcUaService Service { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OPC UA port assigned to this fixture instance.
|
||||
/// </summary>
|
||||
public int OpcUaPort { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OPC UA endpoint URL exposed by the fixture.
|
||||
/// </summary>
|
||||
public string EndpointUrl => $"opc.tcp://localhost:{OpcUaPort}/LmxOpcUa";
|
||||
|
||||
/// <summary>
|
||||
/// The fake Galaxy repository injected into the service. Mutate Hierarchy/Attributes
|
||||
/// then call Service.TriggerRebuild() to simulate a Galaxy redeployment.
|
||||
/// </summary>
|
||||
public FakeGalaxyRepository? GalaxyRepository { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The fake MxAccess client injected into the service (when using WithFakeMxAccessClient).
|
||||
/// </summary>
|
||||
public FakeMxAccessClient? MxAccessClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The fake MxProxy injected into the service (when using WithFakes).
|
||||
/// </summary>
|
||||
public FakeMxProxy? MxProxy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds and starts the OPC UA service for the current fixture.
|
||||
/// </summary>
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
Service = _builder.Build();
|
||||
Service.Start();
|
||||
_started = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the OPC UA service when the fixture had previously been started.
|
||||
/// </summary>
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
if (_started)
|
||||
try
|
||||
{
|
||||
Service.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* swallow cleanup errors */
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates fixture with FakeMxProxy + FakeGalaxyRepository (standard test data).
|
||||
/// The STA thread and COM interop run against FakeMxProxy.
|
||||
/// </summary>
|
||||
/// <param name="proxy">An optional fake proxy to inject; otherwise a default fake is created.</param>
|
||||
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
|
||||
/// <returns>A fixture configured to exercise the COM-style runtime path.</returns>
|
||||
public static OpcUaServerFixture WithFakes(
|
||||
FakeMxProxy? proxy = null,
|
||||
FakeGalaxyRepository? repo = null)
|
||||
{
|
||||
var p = proxy ?? new FakeMxProxy();
|
||||
var r = repo ?? new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = TestData.CreateStandardHierarchy(),
|
||||
Attributes = TestData.CreateStandardAttributes()
|
||||
};
|
||||
|
||||
var builder = new OpcUaServiceBuilder()
|
||||
.WithMxProxy(p)
|
||||
.WithGalaxyRepository(r)
|
||||
.WithGalaxyName("TestGalaxy");
|
||||
|
||||
return new OpcUaServerFixture(builder, r, mxProxy: p);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates fixture using FakeMxAccessClient directly — skips STA thread + COM entirely.
|
||||
/// Fastest option for tests that don't need real COM interop.
|
||||
/// </summary>
|
||||
/// <param name="mxClient">An optional fake MXAccess client to inject; otherwise a default fake is created.</param>
|
||||
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
|
||||
/// <param name="security">An optional security profile configuration for the test server.</param>
|
||||
/// <param name="redundancy">An optional redundancy configuration for the test server.</param>
|
||||
/// <param name="applicationUri">An optional explicit application URI for the test server.</param>
|
||||
/// <param name="serverName">An optional server name override for the test server.</param>
|
||||
/// <returns>A fixture configured to exercise the direct fake-client path.</returns>
|
||||
public static OpcUaServerFixture WithFakeMxAccessClient(
|
||||
FakeMxAccessClient? mxClient = null,
|
||||
FakeGalaxyRepository? repo = null,
|
||||
SecurityProfileConfiguration? security = null,
|
||||
RedundancyConfiguration? redundancy = null,
|
||||
string? applicationUri = null,
|
||||
string? serverName = null,
|
||||
AuthenticationConfiguration? authConfig = null,
|
||||
IUserAuthenticationProvider? authProvider = null,
|
||||
bool alarmTrackingEnabled = false,
|
||||
string[]? alarmObjectFilters = null)
|
||||
{
|
||||
var client = mxClient ?? new FakeMxAccessClient();
|
||||
var r = repo ?? new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = TestData.CreateStandardHierarchy(),
|
||||
Attributes = TestData.CreateStandardAttributes()
|
||||
};
|
||||
|
||||
var builder = new OpcUaServiceBuilder()
|
||||
.WithMxAccessClient(client)
|
||||
.WithGalaxyRepository(r)
|
||||
.WithGalaxyName("TestGalaxy");
|
||||
|
||||
if (security != null)
|
||||
builder.WithSecurity(security);
|
||||
if (redundancy != null)
|
||||
builder.WithRedundancy(redundancy);
|
||||
if (applicationUri != null)
|
||||
builder.WithApplicationUri(applicationUri);
|
||||
if (serverName != null)
|
||||
builder.WithGalaxyName(serverName);
|
||||
if (authConfig != null)
|
||||
builder.WithAuthentication(authConfig);
|
||||
if (authProvider != null)
|
||||
builder.WithAuthProvider(authProvider);
|
||||
if (alarmTrackingEnabled)
|
||||
builder.WithAlarmTracking(true);
|
||||
if (alarmObjectFilters != null)
|
||||
builder.WithAlarmFilter(alarmObjectFilters);
|
||||
|
||||
return new OpcUaServerFixture(builder, r, client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the node manager currently published by the running fixture so tests can assert
|
||||
/// filter counters, alarm condition counts, and other runtime telemetry.
|
||||
/// </summary>
|
||||
public ZB.MOM.WW.OtOpcUa.Host.OpcUa.LmxNodeManager? NodeManager => Service.NodeManagerInstance;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the reusable OPC UA server fixture used by integration and wiring tests.
|
||||
/// </summary>
|
||||
public class OpcUaServerFixtureTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the standard fake-backed fixture starts the bridge and tears it down cleanly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WithFakes_StartsAndStops()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
|
||||
fixture.Service.ShouldNotBeNull();
|
||||
fixture.Service.MxClient.ShouldNotBeNull();
|
||||
fixture.Service.MxClient!.State.ShouldBe(ConnectionState.Connected);
|
||||
fixture.Service.GalaxyStatsInstance.ShouldNotBeNull();
|
||||
fixture.Service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy");
|
||||
fixture.OpcUaPort.ShouldBeGreaterThan(16000);
|
||||
fixture.EndpointUrl.ShouldContain(fixture.OpcUaPort.ToString());
|
||||
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the fake-client fixture bypasses COM wiring and uses the provided fake runtime client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WithFakeMxAccessClient_SkipsCom()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
|
||||
fixture.Service.MxClient.ShouldBe(mxClient);
|
||||
mxClient.State.ShouldBe(ConnectionState.Connected);
|
||||
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that separate fixture instances automatically allocate unique OPC UA ports.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultipleFixtures_GetUniquePortsAutomatically()
|
||||
{
|
||||
var fixture1 = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
var fixture2 = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
|
||||
fixture1.OpcUaPort.ShouldNotBe(fixture2.OpcUaPort);
|
||||
|
||||
// Both can start without port conflicts
|
||||
await fixture1.InitializeAsync();
|
||||
await fixture2.InitializeAsync();
|
||||
|
||||
fixture1.Service.ShouldNotBeNull();
|
||||
fixture2.Service.ShouldNotBeNull();
|
||||
|
||||
await fixture1.DisposeAsync();
|
||||
await fixture2.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that fixture shutdown completes quickly enough for the integration test suite.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_CompletesWithin30Seconds()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await fixture.DisposeAsync();
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.TotalSeconds.ShouldBeLessThan(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that runtime callbacks arriving after shutdown are ignored cleanly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Stop_UnhooksNodeManagerFromMxAccessCallbacks()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
|
||||
await fixture.DisposeAsync();
|
||||
|
||||
Should.NotThrow(() => mxClient.SimulateDataChange("TestMachine_001.MachineID", Vtq.Good(42)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the fake-backed fixture builds the seeded address space and Galaxy statistics.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WithFakes_BuildsAddressSpace()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
|
||||
fixture.Service.GalaxyStatsInstance!.ObjectCount.ShouldBe(5);
|
||||
fixture.Service.GalaxyStatsInstance.AttributeCount.ShouldBe(6);
|
||||
fixture.Service.GalaxyStatsInstance.DbConnected.ShouldBe(true);
|
||||
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC UA client helper for integration tests. Connects to a test server,
|
||||
/// browses, reads, and subscribes to nodes programmatically.
|
||||
/// </summary>
|
||||
internal class OpcUaTestClient : IDisposable
|
||||
{
|
||||
private Session? _session;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active OPC UA session used by integration tests once the helper has connected to the bridge.
|
||||
/// </summary>
|
||||
public Session Session => _session ?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
/// <summary>
|
||||
/// Closes the test session and releases OPC UA client resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_session != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_session.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
_session.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the namespace index for a given namespace URI (e.g., "urn:TestGalaxy:LmxOpcUa").
|
||||
/// </summary>
|
||||
/// <param name="galaxyName">The Galaxy name whose OPC UA namespace should be resolved on the test server.</param>
|
||||
/// <returns>The namespace index assigned by the server for the requested Galaxy namespace.</returns>
|
||||
public ushort GetNamespaceIndex(string galaxyName = "TestGalaxy")
|
||||
{
|
||||
var nsUri = $"urn:{galaxyName}:LmxOpcUa";
|
||||
var idx = Session.NamespaceUris.GetIndex(nsUri);
|
||||
if (idx < 0) throw new InvalidOperationException($"Namespace '{nsUri}' not found on server");
|
||||
return (ushort)idx;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a NodeId in the LmxOpcUa namespace using the server's actual namespace index.
|
||||
/// </summary>
|
||||
/// <param name="identifier">The string identifier for the node inside the Galaxy namespace.</param>
|
||||
/// <param name="galaxyName">The Galaxy name whose namespace should be used for the node identifier.</param>
|
||||
/// <returns>A node identifier that targets the requested node on the test server.</returns>
|
||||
public NodeId MakeNodeId(string identifier, string galaxyName = "TestGalaxy")
|
||||
{
|
||||
return new NodeId(identifier, GetNamespaceIndex(galaxyName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects the helper to an OPC UA endpoint exposed by the test bridge.
|
||||
/// </summary>
|
||||
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
|
||||
/// <param name="securityMode">The requested message security mode (default: None).</param>
|
||||
/// <param name="username">Optional username for authenticated connections.</param>
|
||||
/// <param name="password">Optional password for authenticated connections.</param>
|
||||
public async Task ConnectAsync(string endpointUrl,
|
||||
MessageSecurityMode securityMode = MessageSecurityMode.None,
|
||||
string? username = null, string? password = null)
|
||||
{
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OpcUaTestClient",
|
||||
ApplicationUri = "urn:localhost:OpcUaTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "own")
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "issuer")
|
||||
},
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "trusted")
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "rejected")
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = true
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 30000 },
|
||||
TransportQuotas = new TransportQuotas()
|
||||
};
|
||||
|
||||
await config.Validate(ApplicationType.Client);
|
||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
EndpointDescription endpoint;
|
||||
if (securityMode != MessageSecurityMode.None)
|
||||
{
|
||||
// Ensure client certificate exists for secure connections
|
||||
var app = new ApplicationInstance
|
||||
{
|
||||
ApplicationName = "OpcUaTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificate(false, 2048);
|
||||
|
||||
// Discover and select endpoint matching the requested mode
|
||||
endpoint = SelectEndpointByMode(endpointUrl, securityMode);
|
||||
}
|
||||
else
|
||||
{
|
||||
endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
|
||||
}
|
||||
|
||||
var endpointConfig = EndpointConfiguration.Create(config);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||
|
||||
var identity = username != null
|
||||
? new UserIdentity(username, password ?? "")
|
||||
: new UserIdentity();
|
||||
|
||||
_session = await Session.Create(
|
||||
config, configuredEndpoint, false,
|
||||
"OpcUaTestClient", 30000, identity, null);
|
||||
}
|
||||
|
||||
private static EndpointDescription SelectEndpointByMode(string endpointUrl, MessageSecurityMode mode)
|
||||
{
|
||||
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
|
||||
var endpoints = client.GetEndpoints(null);
|
||||
|
||||
foreach (var ep in endpoints)
|
||||
if (ep.SecurityMode == mode && ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
|
||||
{
|
||||
ep.EndpointUrl = endpointUrl;
|
||||
return ep;
|
||||
}
|
||||
|
||||
// Fall back to any matching mode
|
||||
foreach (var ep in endpoints)
|
||||
if (ep.SecurityMode == mode)
|
||||
{
|
||||
ep.EndpointUrl = endpointUrl;
|
||||
return ep;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No endpoint with security mode {mode} found on {endpointUrl}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Browse children of a node. Returns list of (DisplayName, NodeId, NodeClass).
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose hierarchical children should be browsed.</param>
|
||||
/// <returns>The child nodes exposed beneath the requested node.</returns>
|
||||
public async Task<List<(string Name, NodeId NodeId, NodeClass NodeClass)>> BrowseAsync(NodeId nodeId)
|
||||
{
|
||||
var results = new List<(string, NodeId, NodeClass)>();
|
||||
var browser = new Browser(Session)
|
||||
{
|
||||
NodeClassMask = (int)NodeClass.Object | (int)NodeClass.Variable,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
BrowseDirection = BrowseDirection.Forward
|
||||
};
|
||||
|
||||
var refs = browser.Browse(nodeId);
|
||||
foreach (var rd in refs)
|
||||
results.Add((rd.DisplayName.Text, ExpandedNodeId.ToNodeId(rd.NodeId, Session.NamespaceUris),
|
||||
rd.NodeClass));
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a node's value.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose current value should be read from the server.</param>
|
||||
/// <returns>The OPC UA data value returned by the server.</returns>
|
||||
public DataValue Read(NodeId nodeId)
|
||||
{
|
||||
return Session.ReadValue(nodeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a specific OPC UA attribute from a node.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose attribute should be read.</param>
|
||||
/// <param name="attributeId">The OPC UA attribute identifier to read.</param>
|
||||
/// <returns>The attribute value returned by the server.</returns>
|
||||
public DataValue ReadAttribute(NodeId nodeId, uint attributeId)
|
||||
{
|
||||
var nodesToRead = new ReadValueIdCollection
|
||||
{
|
||||
new ReadValueId
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = attributeId
|
||||
}
|
||||
};
|
||||
|
||||
Session.Read(
|
||||
null,
|
||||
0,
|
||||
TimestampsToReturn.Neither,
|
||||
nodesToRead,
|
||||
out var results,
|
||||
out _);
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a node's value, optionally using an OPC UA index range for array element writes.
|
||||
/// Returns the server status code for the write.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose value should be written.</param>
|
||||
/// <param name="value">The value to send to the server.</param>
|
||||
/// <param name="indexRange">An optional OPC UA index range used for array element writes.</param>
|
||||
/// <returns>The server status code returned for the write request.</returns>
|
||||
public StatusCode Write(NodeId nodeId, object value, string? indexRange = null)
|
||||
{
|
||||
var nodesToWrite = new WriteValueCollection
|
||||
{
|
||||
new WriteValue
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
IndexRange = indexRange,
|
||||
Value = new DataValue(new Variant(value))
|
||||
}
|
||||
};
|
||||
|
||||
Session.Write(null, nodesToWrite, out var results, out _);
|
||||
return results[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a subscription with a monitored item on the given node.
|
||||
/// Returns the subscription and monitored item for inspection.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node whose value changes should be monitored.</param>
|
||||
/// <param name="intervalMs">The publishing and sampling interval, in milliseconds, for the test subscription.</param>
|
||||
/// <returns>The created subscription and monitored item pair for later assertions and cleanup.</returns>
|
||||
public async Task<(Subscription Sub, MonitoredItem Item)> SubscribeAsync(
|
||||
NodeId nodeId, int intervalMs = 250)
|
||||
{
|
||||
var subscription = new Subscription(Session.DefaultSubscription)
|
||||
{
|
||||
PublishingInterval = intervalMs,
|
||||
DisplayName = "TestSubscription"
|
||||
};
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = nodeId,
|
||||
DisplayName = nodeId.ToString(),
|
||||
SamplingInterval = intervalMs
|
||||
};
|
||||
|
||||
subscription.AddItem(item);
|
||||
Session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync();
|
||||
|
||||
return (subscription, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Reusable test data matching the Galaxy hierarchy from gr/layout.md.
|
||||
/// </summary>
|
||||
public static class TestData
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the standard Galaxy hierarchy used by integration and wiring tests.
|
||||
/// </summary>
|
||||
/// <returns>The standard hierarchy rows for the fake repository.</returns>
|
||||
public static List<GalaxyObjectInfo> CreateStandardHierarchy()
|
||||
{
|
||||
return new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0,
|
||||
IsArea = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea",
|
||||
ParentGobjectId = 1, IsArea = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001",
|
||||
BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver",
|
||||
BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 5, TagName = "MESReceiver_001", ContainedName = "MESReceiver",
|
||||
BrowseName = "MESReceiver", ParentGobjectId = 3, IsArea = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the standard attribute set used by integration and wiring tests.
|
||||
/// </summary>
|
||||
/// <returns>The standard attribute rows for the fake repository.</returns>
|
||||
public static List<GalaxyAttributeInfo> CreateStandardAttributes()
|
||||
{
|
||||
return new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID",
|
||||
FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineCode",
|
||||
FullTagReference = "TestMachine_001.MachineCode", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath",
|
||||
FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber",
|
||||
FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInBatchID",
|
||||
FullTagReference = "MESReceiver_001.MoveInBatchID", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInPartNumbers",
|
||||
FullTagReference = "MESReceiver_001.MoveInPartNumbers[]", MxDataType = 5, IsArray = true,
|
||||
ArrayDimension = 50
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal hierarchy containing a single object for focused unit tests.
|
||||
/// </summary>
|
||||
/// <returns>A minimal hierarchy row set.</returns>
|
||||
public static List<GalaxyObjectInfo> CreateMinimalHierarchy()
|
||||
{
|
||||
return new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal attribute set containing a single scalar attribute for focused unit tests.
|
||||
/// </summary>
|
||||
/// <returns>A minimal attribute row set.</returns>
|
||||
public static List<GalaxyAttributeInfo> CreateMinimalAttributes()
|
||||
{
|
||||
return new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr",
|
||||
FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Historian
|
||||
{
|
||||
public class HistorianAggregateMapTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_Average_ReturnsAverage()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Average).ShouldBe("Average");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_Minimum_ReturnsMinimum()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Minimum).ShouldBe("Minimum");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_Maximum_ReturnsMaximum()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Maximum).ShouldBe("Maximum");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_Count_ReturnsValueCount()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Count).ShouldBe("ValueCount");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_Start_ReturnsFirst()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Start).ShouldBe("First");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_End_ReturnsLast()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_End).ShouldBe("Last");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_StdDev_ReturnsStdDev()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_StandardDeviationPopulation)
|
||||
.ShouldBe("StdDev");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregateToColumn_Unsupported_ReturnsNull()
|
||||
{
|
||||
HistorianAggregateMap.MapAggregateToColumn(new NodeId(99999)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the load-outcome state machine of <see cref="HistorianPluginLoader"/>.
|
||||
/// </summary>
|
||||
public class HistorianPluginLoaderTests
|
||||
{
|
||||
/// <summary>
|
||||
/// MarkDisabled publishes a Disabled outcome so the dashboard can distinguish
|
||||
/// "feature off" from "load failed."
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MarkDisabled_PublishesDisabledOutcome()
|
||||
{
|
||||
HistorianPluginLoader.MarkDisabled();
|
||||
|
||||
HistorianPluginLoader.LastOutcome.Status.ShouldBe(HistorianPluginStatus.Disabled);
|
||||
HistorianPluginLoader.LastOutcome.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the plugin directory is missing, TryLoad reports NotFound — not LoadFailed —
|
||||
/// and returns null so the server can start with history disabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryLoad_PluginMissing_ReturnsNullWithNotFoundOutcome()
|
||||
{
|
||||
// The test process runs from a bin directory that does not contain a Historian/
|
||||
// subfolder, so TryLoad will take the file-missing branch.
|
||||
var config = new HistorianConfiguration { Enabled = true };
|
||||
|
||||
var result = HistorianPluginLoader.TryLoad(config);
|
||||
|
||||
result.ShouldBeNull();
|
||||
HistorianPluginLoader.LastOutcome.Status.ShouldBe(HistorianPluginStatus.NotFound);
|
||||
HistorianPluginLoader.LastOutcome.PluginPath.ShouldContain("ZB.MOM.WW.OtOpcUa.Historian.Aveva.dll");
|
||||
HistorianPluginLoader.LastOutcome.Error.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Historian
|
||||
{
|
||||
public class HistorianQualityMappingTests
|
||||
{
|
||||
private static StatusCode MapHistorianQuality(byte quality)
|
||||
{
|
||||
return QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(192)] // Quality.Good
|
||||
[InlineData(216)] // Quality.GoodLocalOverride
|
||||
public void GoodQualityRange_MapsToGood(byte quality)
|
||||
{
|
||||
StatusCode.IsGood(MapHistorianQuality(quality)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(64)] // Quality.Uncertain
|
||||
[InlineData(68)] // Quality.UncertainLastUsable
|
||||
[InlineData(80)] // Quality.UncertainSensorNotAccurate
|
||||
[InlineData(88)] // Quality.UncertainSubNormal
|
||||
[InlineData(128)] // Uncertain range (no exact enum match)
|
||||
public void UncertainQualityRange_MapsToUncertain(byte quality)
|
||||
{
|
||||
StatusCode.IsUncertain(MapHistorianQuality(quality)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // Quality.Bad
|
||||
[InlineData(1)] // Bad range
|
||||
[InlineData(4)] // Quality.BadConfigError
|
||||
[InlineData(8)] // Quality.BadNotConnected
|
||||
[InlineData(20)] // Quality.BadCommFailure
|
||||
[InlineData(50)] // Bad range (no exact enum match)
|
||||
public void BadQualityRange_MapsToBad(byte quality)
|
||||
{
|
||||
StatusCode.IsBad(MapHistorianQuality(quality)).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Historian
|
||||
{
|
||||
public class HistoryContinuationPointTests
|
||||
{
|
||||
private static List<DataValue> CreateTestValues(int count)
|
||||
{
|
||||
var values = new List<DataValue>();
|
||||
for (var i = 0; i < count; i++)
|
||||
values.Add(new DataValue
|
||||
{
|
||||
Value = new Variant((double)i),
|
||||
SourceTimestamp = DateTime.UtcNow.AddSeconds(i),
|
||||
StatusCode = StatusCodes.Good
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReturnsNonEmptyContinuationPoint()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
var values = CreateTestValues(5);
|
||||
|
||||
var cp = mgr.Store(values);
|
||||
|
||||
cp.ShouldNotBeNull();
|
||||
cp.Length.ShouldBe(16); // GUID = 16 bytes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrieve_ValidContinuationPoint_ReturnsStoredValues()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
var values = CreateTestValues(5);
|
||||
var cp = mgr.Store(values);
|
||||
|
||||
var retrieved = mgr.Retrieve(cp);
|
||||
|
||||
retrieved.ShouldNotBeNull();
|
||||
retrieved!.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrieve_SameContinuationPointTwice_ReturnsNullSecondTime()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
var values = CreateTestValues(3);
|
||||
var cp = mgr.Store(values);
|
||||
|
||||
mgr.Retrieve(cp).ShouldNotBeNull();
|
||||
mgr.Retrieve(cp).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrieve_InvalidBytes_ReturnsNull()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
|
||||
mgr.Retrieve(new byte[] { 1, 2, 3 }).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrieve_NullBytes_ReturnsNull()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
|
||||
mgr.Retrieve(null!).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrieve_UnknownGuid_ReturnsNull()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
|
||||
mgr.Retrieve(Guid.NewGuid().ToByteArray()).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_RemovesContinuationPoint()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
var values = CreateTestValues(5);
|
||||
var cp = mgr.Store(values);
|
||||
|
||||
mgr.Release(cp);
|
||||
|
||||
mgr.Retrieve(cp).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retrieve_ExpiredContinuationPoint_ReturnsNull()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager(TimeSpan.FromMilliseconds(1));
|
||||
var values = CreateTestValues(5);
|
||||
var cp = mgr.Store(values);
|
||||
|
||||
System.Threading.Thread.Sleep(50);
|
||||
|
||||
mgr.Retrieve(cp).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_PurgesExpiredEntries()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager(TimeSpan.FromMilliseconds(1));
|
||||
var cp1 = mgr.Store(CreateTestValues(3));
|
||||
var cp2 = mgr.Store(CreateTestValues(5));
|
||||
|
||||
System.Threading.Thread.Sleep(50);
|
||||
|
||||
// Release one — purge should clean both expired entries
|
||||
mgr.Release(cp1);
|
||||
mgr.Retrieve(cp2).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleContinuationPoints_IndependentRetrieval()
|
||||
{
|
||||
var mgr = new HistoryContinuationPointManager();
|
||||
var values1 = CreateTestValues(3);
|
||||
var values2 = CreateTestValues(7);
|
||||
|
||||
var cp1 = mgr.Store(values1);
|
||||
var cp2 = mgr.Store(values2);
|
||||
|
||||
var r1 = mgr.Retrieve(cp1);
|
||||
var r2 = mgr.Retrieve(cp2);
|
||||
|
||||
r1.ShouldNotBeNull();
|
||||
r1!.Count.ShouldBe(3);
|
||||
r2.ShouldNotBeNull();
|
||||
r2!.Count.ShouldBe(7);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class AccessLevelTests
|
||||
{
|
||||
private static FakeGalaxyRepository CreateRepoWithSecurityLevels()
|
||||
{
|
||||
return new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "FreeAttr",
|
||||
FullTagReference = "TestObj.FreeAttr", MxDataType = 5, SecurityClassification = 0
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "OperateAttr",
|
||||
FullTagReference = "TestObj.OperateAttr", MxDataType = 5, SecurityClassification = 1
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "SecuredAttr",
|
||||
FullTagReference = "TestObj.SecuredAttr", MxDataType = 5, SecurityClassification = 2
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "VerifiedAttr",
|
||||
FullTagReference = "TestObj.VerifiedAttr", MxDataType = 5, SecurityClassification = 3
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "TuneAttr",
|
||||
FullTagReference = "TestObj.TuneAttr", MxDataType = 5, SecurityClassification = 4
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "ConfigAttr",
|
||||
FullTagReference = "TestObj.ConfigAttr", MxDataType = 5, SecurityClassification = 5
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "ViewOnlyAttr",
|
||||
FullTagReference = "TestObj.ViewOnlyAttr", MxDataType = 5, SecurityClassification = 6
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writable Galaxy security classifications publish OPC UA variables with read-write access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadWriteAttribute_HasCurrentReadOrWrite_AccessLevel()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
foreach (var attrName in new[] { "FreeAttr", "OperateAttr", "TuneAttr", "ConfigAttr" })
|
||||
{
|
||||
var nodeId = client.MakeNodeId($"TestObj.{attrName}");
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentReadOrWrite,
|
||||
$"{attrName} should be ReadWrite");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that secured and view-only Galaxy classifications publish OPC UA variables with read-only access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadOnlyAttribute_HasCurrentRead_AccessLevel()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
foreach (var attrName in new[] { "SecuredAttr", "VerifiedAttr", "ViewOnlyAttr" })
|
||||
{
|
||||
var nodeId = client.MakeNodeId($"TestObj.{attrName}");
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentRead,
|
||||
$"{attrName} should be ReadOnly");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the bridge rejects writes against Galaxy attributes whose security classification is read-only.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_ToReadOnlyAttribute_IsRejected()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.ViewOnlyAttr");
|
||||
var result = client.Write(nodeId, "test");
|
||||
StatusCode.IsBad(result).ShouldBeTrue("Write to ReadOnly attribute should be rejected");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writes succeed for Galaxy attributes whose security classification permits operator updates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_ToReadWriteAttribute_Succeeds()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient, CreateRepoWithSecurityLevels());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.OperateAttr");
|
||||
var result = client.Write(nodeId, "test");
|
||||
StatusCode.IsGood(result).ShouldBeTrue("Write to ReadWrite attribute should succeed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests verifying dynamic address space changes via a real OPC UA client.
|
||||
/// Tests browse, subscribe, add/remove nodes at runtime, and subscription quality changes.
|
||||
/// </summary>
|
||||
public class AddressSpaceRebuildTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the initial browsed hierarchy matches the seeded Galaxy model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_ReturnsInitialHierarchy()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Browse from ZB root
|
||||
var zbNode = client.MakeNodeId("ZB");
|
||||
var children = await client.BrowseAsync(zbNode);
|
||||
|
||||
children.ShouldContain(c => c.Name == "DEV");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that adding a Galaxy object and rebuilding exposes the new node to OPC UA clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_AfterAddingObject_NewNodeAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify initial state — browse TestMachine_001
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldNotContain(c => c.Name == "NewReceiver");
|
||||
|
||||
// Add a new object to the hierarchy
|
||||
fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewReceiver_001",
|
||||
ContainedName = "NewReceiver", BrowseName = "NewReceiver",
|
||||
ParentGobjectId = 3, IsArea = false // parent = TestMachine_001
|
||||
});
|
||||
fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewReceiver_001",
|
||||
AttributeName = "NewAttr", FullTagReference = "NewReceiver_001.NewAttr",
|
||||
MxDataType = 5, IsArray = false
|
||||
});
|
||||
|
||||
// Trigger rebuild
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500); // allow rebuild to complete
|
||||
|
||||
// Browse again — new node should appear
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldContain(c => c.Name == "NewReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that removing a Galaxy object and rebuilding removes the node from the OPC UA hierarchy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_AfterRemovingObject_NodeDisappears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify MESReceiver exists initially
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldContain(c => c.Name == "MESReceiver");
|
||||
|
||||
// Remove MESReceiver and its attributes from hierarchy
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
||||
|
||||
// Trigger rebuild
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Browse again — MESReceiver should be gone
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldNotContain(c => c.Name == "MESReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscriptions on deleted nodes receive a bad-quality notification after rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_RemovedNode_PublishesBadQuality()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Subscribe to an attribute that will be removed
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInBatchID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
|
||||
// Collect notifications
|
||||
var notifications = new List<MonitoredItemNotification>();
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n)
|
||||
notifications.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500); // let initial subscription settle
|
||||
|
||||
// Remove MESReceiver and its attributes
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
||||
|
||||
// Trigger rebuild — nodes get deleted
|
||||
fixture.Service.TriggerRebuild();
|
||||
|
||||
// Wait for publish cycle to deliver Bad status
|
||||
await Task.Delay(2000);
|
||||
|
||||
// The subscription should have received a Bad quality notification
|
||||
// after the node was deleted during rebuild
|
||||
notifications.ShouldContain(n => StatusCode.IsBad(n.Value.StatusCode));
|
||||
|
||||
await sub.DeleteAsync(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscriptions on surviving nodes continue to work after a partial rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_SurvivingNode_StillWorksAfterRebuild()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Subscribe to an attribute that will survive the rebuild
|
||||
var nodeId = client.MakeNodeId("TestMachine_001.MachineID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Remove only MESReceiver (MachineID on TestMachine_001 survives)
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001");
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001");
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(1000);
|
||||
|
||||
// The surviving node should still be browsable
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var children = await client.BrowseAsync(machineNode);
|
||||
children.ShouldContain(c => c.Name == "MachineID");
|
||||
|
||||
await sub.DeleteAsync(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that adding a Galaxy attribute and rebuilding exposes a new OPC UA variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_AddAttribute_NewVariableAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldNotContain(c => c.Name == "NewSensor");
|
||||
|
||||
// Add a new attribute
|
||||
fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001",
|
||||
AttributeName = "NewSensor", FullTagReference = "TestMachine_001.NewSensor",
|
||||
MxDataType = 4, IsArray = false // Double
|
||||
});
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldContain(c => c.Name == "NewSensor");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that removing a Galaxy attribute and rebuilding removes the OPC UA variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Browse_RemoveAttribute_VariableDisappears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var machineNode = client.MakeNodeId("TestMachine_001");
|
||||
var initialChildren = await client.BrowseAsync(machineNode);
|
||||
initialChildren.ShouldContain(c => c.Name == "MachineCode");
|
||||
|
||||
// Remove MachineCode attribute
|
||||
fixture.GalaxyRepository!.Attributes.RemoveAll(a =>
|
||||
a.TagName == "TestMachine_001" && a.AttributeName == "MachineCode");
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
var updatedChildren = await client.BrowseAsync(machineNode);
|
||||
updatedChildren.ShouldNotContain(c => c.Name == "MachineCode");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that rebuilds preserve subscription bookkeeping for nodes that survive the metadata refresh.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Rebuild_PreservesSubscriptionBookkeeping_ForSurvivingNodes()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
var mxClient = fixture.MxAccessClient!;
|
||||
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(200);
|
||||
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that transferred monitored items recreate MXAccess subscriptions when the service has no local
|
||||
/// subscription state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TransferSubscriptions_RestoresMxAccessSubscriptionState_WhenLocalStateIsMissing()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
var mxClient = fixture.MxAccessClient!;
|
||||
|
||||
nodeManager.RestoreTransferredSubscriptions(new[]
|
||||
{
|
||||
"TestMachine_001.MachineID",
|
||||
"TestMachine_001.MachineID"
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that transferring monitored items does not double-count subscriptions already tracked in memory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TransferSubscriptions_DoesNotDoubleCount_WhenSubscriptionAlreadyTracked()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
var mxClient = fixture.MxAccessClient!;
|
||||
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.RestoreTransferredSubscriptions(new[]
|
||||
{
|
||||
"TestMachine_001.MachineID"
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end integration tests that boot a real LmxNodeManager against fake Galaxy data and verify
|
||||
/// the template-based alarm object filter actually suppresses alarm condition creation in both the
|
||||
/// full build path and the subtree rebuild path after a simulated Galaxy redeploy.
|
||||
/// </summary>
|
||||
public class AlarmObjectFilterIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Filter_Empty_AllAlarmsTracked()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: CreateRepoWithMixedTemplates(),
|
||||
alarmTrackingEnabled: true);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager.ShouldNotBeNull();
|
||||
// Two alarm attributes total (one per object), no filter → both tracked.
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(2);
|
||||
fixture.NodeManager.AlarmFilterEnabled.ShouldBeFalse();
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_MatchesOneTemplate_OnlyMatchingAlarmTracked()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: CreateRepoWithMixedTemplates(),
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "TestMachine*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager.ShouldNotBeNull();
|
||||
fixture.NodeManager!.AlarmFilterEnabled.ShouldBeTrue();
|
||||
fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1);
|
||||
fixture.NodeManager.AlarmConditionCount.ShouldBe(1);
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_MatchesParent_PropagatesToChild()
|
||||
{
|
||||
var attrs = new List<GalaxyAttributeInfo>();
|
||||
attrs.AddRange(AlarmWithInAlarm(1, "Parent_001", "AlarmA"));
|
||||
attrs.AddRange(AlarmWithInAlarm(2, "Child_001", "AlarmB"));
|
||||
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, tag: "Parent_001", template: "TestMachine"),
|
||||
Obj(2, parent: 1, tag: "Child_001", template: "UnrelatedWidget")
|
||||
},
|
||||
Attributes = attrs
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: repo,
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "TestMachine*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(2);
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_NoMatch_ZeroAlarmConditions()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: CreateRepoWithMixedTemplates(),
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "NotInGalaxy*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(0);
|
||||
fixture.NodeManager.AlarmFilterIncludedObjectCount.ShouldBe(0);
|
||||
fixture.NodeManager.AlarmFilterPatternCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filter_GalaxyDollarPrefix_Normalized()
|
||||
{
|
||||
// Template chain stored as "$TestMachine" must match operator pattern "TestMachine*".
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, tag: "Obj_1", template: "$TestMachine")
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>(AlarmWithInAlarm(1, "Obj_1", "AlarmX"))
|
||||
};
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
repo: repo,
|
||||
alarmTrackingEnabled: true,
|
||||
alarmObjectFilters: new[] { "TestMachine*" });
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
fixture.NodeManager!.AlarmConditionCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static FakeGalaxyRepository CreateRepoWithMixedTemplates()
|
||||
{
|
||||
var attrs = new List<GalaxyAttributeInfo>();
|
||||
attrs.AddRange(AlarmWithInAlarm(1, "TestMachine_001", "MachineAlarm"));
|
||||
attrs.AddRange(AlarmWithInAlarm(2, "Pump_001", "PumpAlarm"));
|
||||
return new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, parent: 0, tag: "TestMachine_001", template: "TestMachine"),
|
||||
Obj(2, parent: 0, tag: "Pump_001", template: "Pump")
|
||||
},
|
||||
Attributes = attrs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a Galaxy alarm attribute plus its companion <c>.InAlarm</c> sub-attribute. The alarm
|
||||
/// creation path in LmxNodeManager skips objects whose alarm attribute has no InAlarm variable
|
||||
/// in the tag→node map, so tests must include both rows for alarm conditions to materialize.
|
||||
/// </summary>
|
||||
private static IEnumerable<GalaxyAttributeInfo> AlarmWithInAlarm(int gobjectId, string tag, string attrName)
|
||||
{
|
||||
yield return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
TagName = tag,
|
||||
AttributeName = attrName,
|
||||
FullTagReference = $"{tag}.{attrName}",
|
||||
MxDataType = 1,
|
||||
IsAlarm = true
|
||||
};
|
||||
yield return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
TagName = tag,
|
||||
AttributeName = attrName + ".InAlarm",
|
||||
FullTagReference = $"{tag}.{attrName}.InAlarm",
|
||||
MxDataType = 1,
|
||||
IsAlarm = false
|
||||
};
|
||||
}
|
||||
|
||||
private static GalaxyObjectInfo Obj(int id, int parent, string tag, string template) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
ParentGobjectId = parent,
|
||||
TagName = tag,
|
||||
ContainedName = tag,
|
||||
BrowseName = tag,
|
||||
IsArea = false,
|
||||
TemplateChain = new List<string> { template }
|
||||
};
|
||||
|
||||
private static GalaxyAttributeInfo AlarmAttr(int gobjectId, string tag, string attrName) => new()
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
TagName = tag,
|
||||
AttributeName = attrName,
|
||||
FullTagReference = $"{tag}.{attrName}",
|
||||
MxDataType = 1,
|
||||
IsAlarm = true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies OPC UA indexed array writes against the bridge's whole-array runtime update behavior.
|
||||
/// </summary>
|
||||
public class ArrayWriteTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that writing a single array element updates the correct slot while preserving the rest of the array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SingleArrayElement_UpdatesWholeArrayValue()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
|
||||
Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Browse path: TestMachine_001 -> MESReceiver -> MoveInPartNumbers
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
|
||||
var before = client.Read(nodeId).Value as string[];
|
||||
before.ShouldNotBeNull();
|
||||
before.Length.ShouldBe(50);
|
||||
before[1].ShouldBe("PART-01");
|
||||
|
||||
var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1");
|
||||
StatusCode.IsGood(status).ShouldBe(true);
|
||||
|
||||
var after = client.Read(nodeId).Value as string[];
|
||||
after.ShouldNotBeNull();
|
||||
after.Length.ShouldBe(50);
|
||||
after[0].ShouldBe("PART-00");
|
||||
after[1].ShouldBe("UPDATED-PART");
|
||||
after[2].ShouldBe("PART-02");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that array nodes use bracketless OPC UA node identifiers while still exposing one-dimensional array
|
||||
/// metadata.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ArrayNode_UsesBracketlessNodeId_AndPublishesArrayDimensions()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
|
||||
Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
|
||||
var value = client.Read(nodeId).Value as string[];
|
||||
value.ShouldNotBeNull();
|
||||
value.Length.ShouldBe(50);
|
||||
|
||||
var valueRank = client.ReadAttribute(nodeId, Attributes.ValueRank).Value;
|
||||
valueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
|
||||
var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[];
|
||||
dimensions.ShouldNotBeNull();
|
||||
dimensions.ShouldBe(new uint[] { 50 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a null runtime value for a statically sized array is exposed as a typed fixed-length array.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_NullStaticArray_ReturnsDefaultTypedArray()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null);
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
|
||||
var value = client.Read(nodeId).Value as string[];
|
||||
value.ShouldNotBeNull();
|
||||
value.Length.ShouldBe(50);
|
||||
value.ShouldAllBe(v => v == string.Empty);
|
||||
|
||||
var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[];
|
||||
dimensions.ShouldBe(new uint[] { 50 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that an indexed write also updates the published OPC UA value seen by subscribed clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SingleArrayElement_PublishesUpdatedArrayToSubscribers()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
|
||||
Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
var notifications = new ConcurrentBag<MonitoredItemNotification>();
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||
notifications.Add(notification);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1");
|
||||
StatusCode.IsGood(status).ShouldBe(true);
|
||||
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications.Any(n =>
|
||||
n.Value.Value is string[] values &&
|
||||
values.Length == 50 &&
|
||||
values[0] == "PART-00" &&
|
||||
values[1] == "UPDATED-PART" &&
|
||||
values[2] == "PART-02").ShouldBe(true);
|
||||
|
||||
await sub.DeleteAsync(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that indexed writes succeed even when the current runtime array value is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SingleArrayElement_WhenCurrentArrayIsNull_UsesDefaultArray()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null);
|
||||
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
|
||||
var status = client.Write(nodeId, new[] { "UPDATED-PART" }, "1");
|
||||
StatusCode.IsGood(status).ShouldBe(true);
|
||||
|
||||
var after = client.Read(nodeId).Value as string[];
|
||||
after.ShouldNotBeNull();
|
||||
after.Length.ShouldBe(50);
|
||||
after[0].ShouldBe(string.Empty);
|
||||
after[1].ShouldBe("UPDATED-PART");
|
||||
after[2].ShouldBe(string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class HistorizingFlagTests
|
||||
{
|
||||
private static FakeGalaxyRepository CreateRepo()
|
||||
{
|
||||
return new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "HistorizedAttr",
|
||||
FullTagReference = "TestObj.HistorizedAttr", MxDataType = 2, IsHistorized = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "NormalAttr",
|
||||
FullTagReference = "TestObj.NormalAttr", MxDataType = 5, IsHistorized = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "AlarmAttr",
|
||||
FullTagReference = "TestObj.AlarmAttr", MxDataType = 1, IsAlarm = true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that historized Galaxy attributes advertise OPC UA historizing support and history-read access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HistorizedAttribute_HasHistorizingTrue_AndHistoryReadAccess()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.HistorizedAttr");
|
||||
var historizing = client.ReadAttribute(nodeId, Attributes.Historizing);
|
||||
((bool)historizing.Value).ShouldBeTrue();
|
||||
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
var level = (byte)accessLevel.Value;
|
||||
(level & AccessLevels.HistoryRead).ShouldBe(AccessLevels.HistoryRead,
|
||||
"HistoryRead bit should be set");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that non-historized Galaxy attributes do not claim OPC UA history support.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NormalAttribute_HasHistorizingFalse_AndNoHistoryReadAccess()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestObj.NormalAttr");
|
||||
var historizing = client.ReadAttribute(nodeId, Attributes.Historizing);
|
||||
((bool)historizing.Value).ShouldBeFalse();
|
||||
|
||||
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
|
||||
var level = (byte)accessLevel.Value;
|
||||
(level & AccessLevels.HistoryRead).ShouldBe(0,
|
||||
"HistoryRead bit should not be set");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class IncrementalSyncTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that adding a new Galaxy object and attribute causes the corresponding OPC UA node subtree to appear after
|
||||
/// sync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_AddObject_NewNodeAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify initial state
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestArea"));
|
||||
children.Select(c => c.Name).ShouldContain("TestMachine_001");
|
||||
children.Select(c => c.Name).ShouldNotContain("NewObject");
|
||||
|
||||
// Add a new object
|
||||
fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewObject_001", ContainedName = "NewObject",
|
||||
BrowseName = "NewObject", ParentGobjectId = 2, IsArea = false
|
||||
});
|
||||
fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 100, TagName = "NewObject_001", AttributeName = "Status",
|
||||
FullTagReference = "NewObject_001.Status", MxDataType = 5
|
||||
});
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(1000);
|
||||
|
||||
// Reconnect in case session was disrupted during rebuild
|
||||
using var client2 = new OpcUaTestClient();
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// New object should appear when browsing parent
|
||||
children = await client2.BrowseAsync(client2.MakeNodeId("TestArea"));
|
||||
children.Select(c => c.Name).ShouldContain("NewObject",
|
||||
$"Browse returned: [{string.Join(", ", children.Select(c => c.Name))}]");
|
||||
|
||||
// Original object should still be there
|
||||
children.Select(c => c.Name).ShouldContain("TestMachine_001");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removing a Galaxy object tears down the corresponding OPC UA subtree without affecting siblings.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_RemoveObject_NodeDisappears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Verify MESReceiver exists
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldContain("MESReceiver");
|
||||
|
||||
// Remove MESReceiver (gobject_id 5) and its attributes
|
||||
fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.GobjectId == 5);
|
||||
fixture.GalaxyRepository.Attributes.RemoveAll(a => a.GobjectId == 5);
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// MESReceiver should be gone
|
||||
children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldNotContain("MESReceiver");
|
||||
|
||||
// DelmiaReceiver should still be there
|
||||
children.Select(c => c.Name).ShouldContain("DelmiaReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that adding a Galaxy attribute creates a new OPC UA variable during incremental rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_AddAttribute_NewVariableAppears()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Add a new attribute to TestMachine_001
|
||||
fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "NewAttr",
|
||||
FullTagReference = "TestMachine_001.NewAttr", MxDataType = 5
|
||||
});
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldContain("NewAttr");
|
||||
children.Select(c => c.Name).ShouldContain("MachineID");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subscriptions on unchanged objects continue receiving data after unrelated subtree rebuilds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_UnchangedObject_SubscriptionSurvives()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Subscribe to MachineID on TestMachine_001
|
||||
var nodeId = client.MakeNodeId("TestMachine_001.MachineID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId);
|
||||
await Task.Delay(500);
|
||||
|
||||
// Modify a DIFFERENT object (MESReceiver) — TestMachine_001 should be unaffected
|
||||
var mesAttr = fixture.GalaxyRepository!.Attributes
|
||||
.First(a => a.GobjectId == 5 && a.AttributeName == "MoveInBatchID");
|
||||
mesAttr.SecurityClassification = 2; // change something
|
||||
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Push a value change through MXAccess — subscription should still deliver
|
||||
mxClient.SimulateDataChange("TestMachine_001.MachineID", Vtq.Good("UPDATED"));
|
||||
await Task.Delay(1000);
|
||||
|
||||
var lastValue = (item.LastValue as MonitoredItemNotification)?.Value?.Value;
|
||||
lastValue.ShouldBe("UPDATED");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a rebuild request with no repository changes leaves the published namespace intact.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sync_NoChanges_NothingHappens()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// Trigger rebuild with no changes
|
||||
fixture.Service.TriggerRebuild();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Everything should still work
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("TestMachine_001"));
|
||||
children.Select(c => c.Name).ShouldContain("MachineID");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration tests verifying multi-client subscription sync and concurrent operations.
|
||||
/// </summary>
|
||||
public class MultiClientTests
|
||||
{
|
||||
// ── Subscription Sync ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that multiple OPC UA clients subscribed to the same tag all receive the same runtime update.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultipleClients_SubscribeToSameTag_AllReceiveDataChanges()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
var notifications = new ConcurrentDictionary<int, List<MonitoredItemNotification>>();
|
||||
var subscriptions = new List<Subscription>();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(client);
|
||||
|
||||
var nodeId = client.MakeNodeId("TestMachine_001.MachineID");
|
||||
var (sub, item) = await client.SubscribeAsync(nodeId, 100);
|
||||
subscriptions.Add(sub);
|
||||
|
||||
var clientIndex = i;
|
||||
notifications[clientIndex] = new List<MonitoredItemNotification>();
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n)
|
||||
notifications[clientIndex].Add(n);
|
||||
};
|
||||
}
|
||||
|
||||
await Task.Delay(500); // let subscriptions settle
|
||||
|
||||
// Simulate data change
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "MACHINE_42");
|
||||
await Task.Delay(1000); // let publish cycle deliver
|
||||
|
||||
// All 3 clients should have received the notification
|
||||
for (var i = 0; i < 3; i++)
|
||||
notifications[i].Count.ShouldBeGreaterThan(0, $"Client {i} did not receive notification");
|
||||
|
||||
foreach (var sub in subscriptions) await sub.DeleteAsync(true);
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that one client disconnecting does not stop remaining clients from receiving updates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_Disconnects_OtherClientsStillReceive()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
var client3 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
await client3.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var notifications1 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
var notifications3 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
|
||||
var (sub1, item1) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub2, _) = await client2.SubscribeAsync(client2.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub3, item3) = await client3.SubscribeAsync(client3.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
|
||||
item1.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications1.Add(n);
|
||||
};
|
||||
item3.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications3.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Disconnect client 2
|
||||
client2.Dispose();
|
||||
|
||||
await Task.Delay(500); // let server process disconnect
|
||||
|
||||
// Simulate data change — should not crash, clients 1+3 should still receive
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "AFTER_DISCONNECT");
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications1.Count.ShouldBeGreaterThan(0,
|
||||
"Client 1 should still receive after client 2 disconnected");
|
||||
notifications3.Count.ShouldBeGreaterThan(0,
|
||||
"Client 3 should still receive after client 2 disconnected");
|
||||
|
||||
await sub1.DeleteAsync(true);
|
||||
await sub3.DeleteAsync(true);
|
||||
client1.Dispose();
|
||||
client3.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that one client unsubscribing does not interrupt delivery to other subscribed clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Client_Unsubscribes_OtherClientsStillReceive()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var notifications2 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
|
||||
var (sub1, _) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub2, item2) = await client2.SubscribeAsync(client2.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
item2.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications2.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Client 1 unsubscribes
|
||||
await sub1.DeleteAsync(true);
|
||||
await Task.Delay(500);
|
||||
|
||||
// Simulate data change — client 2 should still receive
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "AFTER_UNSUB");
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications2.Count.ShouldBeGreaterThan(0,
|
||||
"Client 2 should still receive after client 1 unsubscribed");
|
||||
|
||||
await sub2.DeleteAsync(true);
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that clients subscribed to different tags only receive updates for their own monitored data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultipleClients_SubscribeToDifferentTags_EachGetsOwnData()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var notifications1 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
var notifications2 = new ConcurrentBag<MonitoredItemNotification>();
|
||||
|
||||
var (sub1, item1) = await client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 100);
|
||||
var (sub2, item2) =
|
||||
await client2.SubscribeAsync(client2.MakeNodeId("DelmiaReceiver_001.DownloadPath"), 100);
|
||||
|
||||
item1.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications1.Add(n);
|
||||
};
|
||||
item2.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification n) notifications2.Add(n);
|
||||
};
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
// Only change MachineID
|
||||
fixture.MxProxy!.SimulateDataChangeByAddress("TestMachine_001.MachineID", "CHANGED");
|
||||
await Task.Delay(1000);
|
||||
|
||||
notifications1.Count.ShouldBeGreaterThan(0, "Client 1 should receive MachineID change");
|
||||
// Client 2 subscribed to DownloadPath, should NOT receive MachineID change
|
||||
// (it may have received initial BadWaitingForInitialData, but not the "CHANGED" value)
|
||||
var client2HasMachineIdValue = notifications2.Any(n =>
|
||||
n.Value.Value is string s && s == "CHANGED");
|
||||
client2HasMachineIdValue.ShouldBe(false, "Client 2 should not receive MachineID data");
|
||||
|
||||
await sub1.DeleteAsync(true);
|
||||
await sub2.DeleteAsync(true);
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concurrent Operation Tests ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that concurrent browse operations from several clients all complete successfully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentBrowseFromMultipleClients_AllSucceed()
|
||||
{
|
||||
// Tests concurrent browse operations from 5 clients — browses don't go through MxAccess
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var c = new OpcUaTestClient();
|
||||
await c.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
var nodes = new[]
|
||||
{
|
||||
"ZB", "TestMachine_001", "DelmiaReceiver_001",
|
||||
"MESReceiver_001", "TestMachine_001"
|
||||
};
|
||||
|
||||
// All 5 clients browse simultaneously
|
||||
var browseTasks = clients.Select((c, i) =>
|
||||
c.BrowseAsync(c.MakeNodeId(nodes[i]))).ToArray();
|
||||
|
||||
var results = await Task.WhenAll(browseTasks);
|
||||
|
||||
results.Length.ShouldBe(5);
|
||||
foreach (var r in results)
|
||||
r.ShouldNotBeEmpty();
|
||||
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that concurrent browse requests return consistent results across clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentBrowse_AllReturnSameResults()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var c = new OpcUaTestClient();
|
||||
await c.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
// All browse TestMachine_001 simultaneously
|
||||
var browseTasks = clients.Select(c =>
|
||||
c.BrowseAsync(c.MakeNodeId("TestMachine_001"))).ToArray();
|
||||
|
||||
var results = await Task.WhenAll(browseTasks);
|
||||
|
||||
// All should get identical child lists
|
||||
var firstResult = results[0].Select(r => r.Name).OrderBy(n => n).ToList();
|
||||
for (var i = 1; i < results.Length; i++)
|
||||
{
|
||||
var thisResult = results[i].Select(r => r.Name).OrderBy(n => n).ToList();
|
||||
thisResult.ShouldBe(firstResult, $"Client {i} got different browse results");
|
||||
}
|
||||
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that simultaneous browse and subscribe operations do not interfere with one another.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentBrowseAndSubscribe_NoInterference()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var clients = new List<OpcUaTestClient>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var c = new OpcUaTestClient();
|
||||
await c.ConnectAsync(fixture.EndpointUrl);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
// 2 browse + 2 subscribe simultaneously
|
||||
var tasks = new Task[]
|
||||
{
|
||||
clients[0].BrowseAsync(clients[0].MakeNodeId("TestMachine_001")),
|
||||
clients[1].BrowseAsync(clients[1].MakeNodeId("ZB")),
|
||||
clients[2].SubscribeAsync(clients[2].MakeNodeId("TestMachine_001.MachineID"), 200),
|
||||
clients[3].SubscribeAsync(clients[3].MakeNodeId("DelmiaReceiver_001.DownloadPath"), 200)
|
||||
};
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
// All should complete without errors
|
||||
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that concurrent subscribe, read, and browse operations complete without deadlocking the server.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentSubscribeAndRead_NoDeadlock()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var client1 = new OpcUaTestClient();
|
||||
var client2 = new OpcUaTestClient();
|
||||
var client3 = new OpcUaTestClient();
|
||||
await client1.ConnectAsync(fixture.EndpointUrl);
|
||||
await client2.ConnectAsync(fixture.EndpointUrl);
|
||||
await client3.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
// All three operate simultaneously — should not deadlock
|
||||
var timeout = Task.Delay(TimeSpan.FromSeconds(15));
|
||||
var operations = Task.WhenAll(
|
||||
client1.SubscribeAsync(client1.MakeNodeId("TestMachine_001.MachineID"), 200)
|
||||
.ContinueWith(t => (object)t.Result),
|
||||
Task.Run(() => (object)client2.Read(client2.MakeNodeId("DelmiaReceiver_001.DownloadPath"))),
|
||||
client3.BrowseAsync(client3.MakeNodeId("TestMachine_001"))
|
||||
.ContinueWith(t => (object)t.Result)
|
||||
);
|
||||
|
||||
var completed = await Task.WhenAny(operations, timeout);
|
||||
completed.ShouldBe(operations, "Operations should complete before timeout (possible deadlock)");
|
||||
|
||||
client1.Dispose();
|
||||
client2.Dispose();
|
||||
client3.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that repeated client churn does not leave the server in an unstable state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RapidConnectDisconnect_ServerStaysStable()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakes();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
// Rapidly connect, browse, disconnect — 10 iterations
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
var children = await client.BrowseAsync(client.MakeNodeId("ZB"));
|
||||
children.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
// After all that churn, server should still be responsive
|
||||
using var finalClient = new OpcUaTestClient();
|
||||
await finalClient.ConnectAsync(fixture.EndpointUrl);
|
||||
var finalChildren = await finalClient.BrowseAsync(finalClient.MakeNodeId("TestMachine_001"));
|
||||
finalChildren.ShouldContain(c => c.Name == "MachineID");
|
||||
finalChildren.ShouldContain(c => c.Name == "DelmiaReceiver");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class PermissionEnforcementTests
|
||||
{
|
||||
private static FakeAuthenticationProvider CreateTestAuthProvider()
|
||||
{
|
||||
return new FakeAuthenticationProvider()
|
||||
.AddUser("readonly", "readonly123", AppRoles.ReadOnly)
|
||||
.AddUser("writeop", "writeop123", AppRoles.WriteOperate)
|
||||
.AddUser("writetune", "writetune123", AppRoles.WriteTune)
|
||||
.AddUser("writeconfig", "writeconfig123", AppRoles.WriteConfigure)
|
||||
.AddUser("alarmack", "alarmack123", AppRoles.AlarmAck)
|
||||
.AddUser("admin", "admin123", AppRoles.ReadOnly, AppRoles.WriteOperate, AppRoles.WriteTune,
|
||||
AppRoles.WriteConfigure, AppRoles.AlarmAck);
|
||||
}
|
||||
|
||||
private static AuthenticationConfiguration CreateAuthConfig(bool anonymousCanWrite = false)
|
||||
{
|
||||
return new AuthenticationConfiguration
|
||||
{
|
||||
AllowAnonymous = true,
|
||||
AnonymousCanWrite = anonymousCanWrite
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymousRead_Allowed()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("hello");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var result = client.Read(client.MakeNodeId("TestMachine_001.MachineID"));
|
||||
result.StatusCode.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymousWrite_Denied_WhenAnonymousCanWriteFalse()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(false),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnonymousWrite_Allowed_WhenAnonymousCanWriteTrue()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(true),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOnlyUser_Write_Denied()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "readonly123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteOperateUser_Write_Allowed()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "writeop", password: "writeop123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmAckOnlyUser_Write_Denied()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "alarmack", password: "alarmack123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminUser_Write_Allowed()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
mxClient,
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "admin", password: "admin123");
|
||||
|
||||
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
|
||||
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidPassword_ConnectionRejected()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
authConfig: CreateAuthConfig(),
|
||||
authProvider: CreateTestAuthProvider());
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(async () =>
|
||||
await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "wrongpassword"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Integration
|
||||
{
|
||||
public class RedundancyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyDisabled_ReportsNone()
|
||||
{
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
|
||||
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.None);
|
||||
|
||||
var serviceLevel = client.Read(VariableIds.Server_ServiceLevel);
|
||||
((byte)serviceLevel.Value).ShouldBe((byte)255);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyEnabled_ReportsConfiguredMode()
|
||||
{
|
||||
var redundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Mode = "Warm",
|
||||
Role = "Primary",
|
||||
ServiceLevelBase = 200,
|
||||
ServerUris = new List<string> { "urn:test:primary", "urn:test:secondary" }
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: redundancy,
|
||||
applicationUri: "urn:test:primary");
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
|
||||
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.Warm);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_Primary_HasHigherServiceLevel_ThanSecondary()
|
||||
{
|
||||
var sharedUris = new List<string> { "urn:test:primary", "urn:test:secondary" };
|
||||
|
||||
var primaryRedundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
var secondaryRedundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Secondary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
|
||||
var primaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: primaryRedundancy, applicationUri: "urn:test:primary");
|
||||
var secondaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: secondaryRedundancy, applicationUri: "urn:test:secondary",
|
||||
serverName: "TestGalaxy2");
|
||||
|
||||
await primaryFixture.InitializeAsync();
|
||||
await secondaryFixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var primaryClient = new OpcUaTestClient();
|
||||
await primaryClient.ConnectAsync(primaryFixture.EndpointUrl);
|
||||
var primaryLevel = (byte)primaryClient.Read(VariableIds.Server_ServiceLevel).Value;
|
||||
|
||||
using var secondaryClient = new OpcUaTestClient();
|
||||
await secondaryClient.ConnectAsync(secondaryFixture.EndpointUrl);
|
||||
var secondaryLevel = (byte)secondaryClient.Read(VariableIds.Server_ServiceLevel).Value;
|
||||
|
||||
primaryLevel.ShouldBeGreaterThan(secondaryLevel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await secondaryFixture.DisposeAsync();
|
||||
await primaryFixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_WithRedundancyEnabled_ExposesServerUriArray()
|
||||
{
|
||||
var serverUris = new List<string> { "urn:test:server1", "urn:test:server2" };
|
||||
var redundancy = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = serverUris
|
||||
};
|
||||
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: redundancy, applicationUri: "urn:test:server1");
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var client = new OpcUaTestClient();
|
||||
await client.ConnectAsync(fixture.EndpointUrl);
|
||||
|
||||
var uriArrayValue = client.Read(VariableIds.Server_ServerRedundancy_ServerUriArray);
|
||||
|
||||
// ServerUriArray may not be exposed if the SDK doesn't create the non-transparent
|
||||
// redundancy node type automatically. If the value is null, the server logged a
|
||||
// warning and the test is informational rather than a hard failure.
|
||||
if (uriArrayValue.Value != null)
|
||||
{
|
||||
var uris = (string[])uriArrayValue.Value;
|
||||
uris.Length.ShouldBe(2);
|
||||
uris.ShouldContain("urn:test:server1");
|
||||
uris.ShouldContain("urn:test:server2");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoServers_BothExposeSameRedundantSet()
|
||||
{
|
||||
var sharedUris = new List<string> { "urn:test:a", "urn:test:b" };
|
||||
var configA = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Primary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
var configB = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true, Mode = "Warm", Role = "Secondary",
|
||||
ServiceLevelBase = 200, ServerUris = sharedUris
|
||||
};
|
||||
|
||||
var fixtureA = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: configA, applicationUri: "urn:test:a");
|
||||
var fixtureB = OpcUaServerFixture.WithFakeMxAccessClient(
|
||||
redundancy: configB, applicationUri: "urn:test:b",
|
||||
serverName: "TestGalaxy2");
|
||||
|
||||
await fixtureA.InitializeAsync();
|
||||
await fixtureB.InitializeAsync();
|
||||
try
|
||||
{
|
||||
using var clientA = new OpcUaTestClient();
|
||||
await clientA.ConnectAsync(fixtureA.EndpointUrl);
|
||||
var modeA = (int)clientA.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
|
||||
|
||||
using var clientB = new OpcUaTestClient();
|
||||
await clientB.ConnectAsync(fixtureB.EndpointUrl);
|
||||
var modeB = (int)clientB.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
|
||||
|
||||
modeA.ShouldBe((int)RedundancySupport.Warm);
|
||||
modeB.ShouldBe((int)RedundancySupport.Warm);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixtureB.DisposeAsync();
|
||||
await fixtureA.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies operation timing aggregation, rolling buffers, and success tracking used by the bridge metrics subsystem.
|
||||
/// </summary>
|
||||
public class PerformanceMetricsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that a fresh metrics collector reports no statistics.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmptyState_ReturnsZeroStatistics()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that repeated operation recordings update total and successful execution counts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RecordOperation_TracksCounts()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10));
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(20), false);
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Read");
|
||||
stats["Read"].TotalCount.ShouldBe(2);
|
||||
stats["Read"].SuccessCount.ShouldBe(1);
|
||||
stats["Read"].SuccessRate.ShouldBe(0.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that min, max, and average timing values are calculated from recorded operations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RecordOperation_TracksMinMaxAverage()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(10));
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(30));
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20));
|
||||
|
||||
var stats = metrics.GetStatistics()["Write"];
|
||||
stats.MinMilliseconds.ShouldBe(10);
|
||||
stats.MaxMilliseconds.ShouldBe(30);
|
||||
stats.AverageMilliseconds.ShouldBe(20);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the 95th percentile is calculated from the recorded timing sample.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void P95_CalculatedCorrectly()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
for (var i = 1; i <= 100; i++)
|
||||
metrics.RecordOperation("Op", TimeSpan.FromMilliseconds(i));
|
||||
|
||||
var stats = metrics.GetStatistics()["Op"];
|
||||
stats.Percentile95Milliseconds.ShouldBe(95);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the rolling buffer keeps the most recent operation durations for percentile calculations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RollingBuffer_EvictsOldEntries()
|
||||
{
|
||||
var opMetrics = new OperationMetrics();
|
||||
for (var i = 0; i < 1100; i++)
|
||||
opMetrics.Record(TimeSpan.FromMilliseconds(i), true);
|
||||
|
||||
var stats = opMetrics.GetStatistics();
|
||||
stats.TotalCount.ShouldBe(1100);
|
||||
// P95 should be from the last 1000 entries (100-1099)
|
||||
stats.Percentile95Milliseconds.ShouldBeGreaterThan(1000);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a timing scope records an operation when disposed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BeginOperation_TimingScopeRecordsOnDispose()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("Test"))
|
||||
{
|
||||
// Simulate some work
|
||||
Thread.Sleep(5);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Test");
|
||||
stats["Test"].TotalCount.ShouldBe(1);
|
||||
stats["Test"].SuccessCount.ShouldBe(1);
|
||||
stats["Test"].AverageMilliseconds.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a timing scope can mark an operation as failed before disposal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BeginOperation_SetSuccessFalse()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("Test"))
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics()["Test"];
|
||||
stats.TotalCount.ShouldBe(1);
|
||||
stats.SuccessCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that looking up an unknown operation returns no metrics bucket.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetMetrics_UnknownOperation_ReturnsNull()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.GetMetrics("NonExistent").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that operation names are tracked without case sensitivity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OperationNames_AreCaseInsensitive()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10));
|
||||
metrics.RecordOperation("read", TimeSpan.FromMilliseconds(20));
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.Count.ShouldBe(1);
|
||||
stats["READ"].TotalCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class AddressSpaceDiffTests
|
||||
{
|
||||
private static GalaxyObjectInfo Obj(int id, string tag, int parent = 0, bool isArea = false)
|
||||
{
|
||||
return new GalaxyObjectInfo
|
||||
{
|
||||
GobjectId = id, TagName = tag, BrowseName = tag, ContainedName = tag, ParentGobjectId = parent,
|
||||
IsArea = isArea
|
||||
};
|
||||
}
|
||||
|
||||
private static GalaxyAttributeInfo Attr(int gobjectId, string name, string tagName = "Obj", int mxDataType = 5)
|
||||
{
|
||||
return new GalaxyAttributeInfo
|
||||
{
|
||||
GobjectId = gobjectId, AttributeName = name, FullTagReference = $"{tagName}.{name}",
|
||||
MxDataType = mxDataType, TagName = tagName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that identical Galaxy hierarchy and attribute snapshots produce no incremental rebuild work.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NoChanges_ReturnsEmptySet()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var a = new List<GalaxyAttributeInfo> { Attr(1, "X") };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, a, h, a);
|
||||
changed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that newly deployed Galaxy objects are flagged for OPC UA subtree creation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddedObject_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var newH = new List<GalaxyObjectInfo> { Obj(1, "A"), Obj(2, "B") };
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(2);
|
||||
changed.ShouldNotContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removed Galaxy objects are flagged so their OPC UA subtree can be torn down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RemovedObject_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A"), Obj(2, "B") };
|
||||
var newH = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(2);
|
||||
changed.ShouldNotContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that browse-name changes are treated as address-space changes for the affected Galaxy object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ModifiedObject_BrowseNameChange_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var newH = new List<GalaxyObjectInfo>
|
||||
{ new() { GobjectId = 1, TagName = "A", BrowseName = "A_Renamed", ContainedName = "A" } };
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that parent changes are treated as subtree moves that require rebuilding the affected object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ModifiedObject_ParentChange_Detected()
|
||||
{
|
||||
var oldH = new List<GalaxyObjectInfo> { Obj(1, "A"), Obj(2, "B", 1) };
|
||||
var newH = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, "A"),
|
||||
new() { GobjectId = 2, TagName = "B", BrowseName = "B", ContainedName = "B", ParentGobjectId = 0 }
|
||||
};
|
||||
var a = new List<GalaxyAttributeInfo>();
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(oldH, a, newH, a);
|
||||
changed.ShouldContain(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that adding a Galaxy attribute marks the owning object for OPC UA variable rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeAdded_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo> { Attr(1, "X") };
|
||||
var newA = new List<GalaxyAttributeInfo> { Attr(1, "X"), Attr(1, "Y") };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that removing a Galaxy attribute marks the owning object for OPC UA variable rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeRemoved_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo> { Attr(1, "X"), Attr(1, "Y") };
|
||||
var newA = new List<GalaxyAttributeInfo> { Attr(1, "X") };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that changes to attribute field metadata such as MX data type trigger rebuild of the owning object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeFieldChange_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo> { Attr(1, "X", mxDataType: 5) };
|
||||
var newA = new List<GalaxyAttributeInfo> { Attr(1, "X", mxDataType: 2) };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that security-classification changes are treated as address-space changes for the owning attribute.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AttributeSecurityChange_Detected()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo> { Obj(1, "A") };
|
||||
var oldA = new List<GalaxyAttributeInfo>
|
||||
{ new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X", SecurityClassification = 1 } };
|
||||
var newA = new List<GalaxyAttributeInfo>
|
||||
{ new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X", SecurityClassification = 2 } };
|
||||
|
||||
var changed = AddressSpaceDiff.FindChangedGobjectIds(h, oldA, h, newA);
|
||||
changed.ShouldContain(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subtree expansion includes all descendants of a changed Galaxy object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExpandToSubtrees_IncludesChildren()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, "Root"),
|
||||
Obj(2, "Child", 1),
|
||||
Obj(3, "Grandchild", 2),
|
||||
Obj(4, "Sibling", 1),
|
||||
Obj(5, "Unrelated")
|
||||
};
|
||||
|
||||
var changed = new HashSet<int> { 1 };
|
||||
var expanded = AddressSpaceDiff.ExpandToSubtrees(changed, h);
|
||||
|
||||
expanded.ShouldContain(1);
|
||||
expanded.ShouldContain(2);
|
||||
expanded.ShouldContain(3);
|
||||
expanded.ShouldContain(4);
|
||||
expanded.ShouldNotContain(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subtree expansion does not introduce unrelated nodes when the changed object is already a leaf.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExpandToSubtrees_LeafNode_NoExpansion()
|
||||
{
|
||||
var h = new List<GalaxyObjectInfo>
|
||||
{
|
||||
Obj(1, "Root"),
|
||||
Obj(2, "Child", 1),
|
||||
Obj(3, "Sibling", 1)
|
||||
};
|
||||
|
||||
var changed = new HashSet<int> { 2 };
|
||||
var expanded = AddressSpaceDiff.ExpandToSubtrees(changed, h);
|
||||
|
||||
expanded.ShouldContain(2);
|
||||
expanded.ShouldNotContain(1);
|
||||
expanded.ShouldNotContain(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
using System;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies how bridge VTQ values are translated to and from OPC UA data values for the published namespace.
|
||||
/// </summary>
|
||||
public class DataValueConverterTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that boolean runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Boolean()
|
||||
{
|
||||
var vtq = Vtq.Good(true);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(true);
|
||||
StatusCode.IsGood(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that integer runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Int32()
|
||||
{
|
||||
var vtq = Vtq.Good(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that float runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Float()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14f);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that double runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_Double()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14159);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that string runtime values are preserved when converted to OPC UA data values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_String()
|
||||
{
|
||||
var vtq = Vtq.Good("hello");
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that UTC timestamps remain UTC when a VTQ is converted for OPC UA clients.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_DateTime_IsUtc()
|
||||
{
|
||||
var utcTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
var vtq = new Vtq(utcTime, utcTime, Quality.Good);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
((DateTime)dv.Value).Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that elapsed-time values are exposed to OPC UA clients in seconds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_TimeSpan_ConvertedToSeconds()
|
||||
{
|
||||
var vtq = Vtq.Good(TimeSpan.FromMinutes(2.5));
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(150.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that string arrays remain arrays when exposed through OPC UA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_StringArray()
|
||||
{
|
||||
var arr = new[] { "a", "b", "c" };
|
||||
var vtq = Vtq.Good(arr);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(arr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that integer arrays remain arrays when exposed through OPC UA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_IntArray()
|
||||
{
|
||||
var arr = new[] { 1, 2, 3 };
|
||||
var vtq = Vtq.Good(arr);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(arr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that bad runtime quality is translated to a bad OPC UA status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_BadQuality_MapsToStatusCode()
|
||||
{
|
||||
var vtq = Vtq.Bad(Quality.BadCommFailure);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
StatusCode.IsBad(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that uncertain runtime quality is translated to an uncertain OPC UA status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_UncertainQuality()
|
||||
{
|
||||
var vtq = Vtq.Uncertain(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
StatusCode.IsUncertain(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that null runtime values remain null when converted for OPC UA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromVtq_NullValue()
|
||||
{
|
||||
var vtq = Vtq.Good(null);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a data value can round-trip back into a VTQ without losing the process value or quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToVtq_RoundTrip()
|
||||
{
|
||||
var original = new Vtq(42, DateTime.UtcNow, Quality.Good);
|
||||
var dv = DataValueConverter.FromVtq(original);
|
||||
var roundTrip = DataValueConverter.ToVtq(dv);
|
||||
|
||||
roundTrip.Value.ShouldBe(42);
|
||||
roundTrip.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the in-memory address-space model built from Galaxy hierarchy and attribute rows.
|
||||
/// </summary>
|
||||
public class LmxNodeManagerBuildTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates representative Galaxy hierarchy and attribute rows for address-space builder tests.
|
||||
/// </summary>
|
||||
/// <returns>The hierarchy and attribute rows used by the tests.</returns>
|
||||
private static (List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes) CreateTestData()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0,
|
||||
IsArea = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea",
|
||||
ParentGobjectId = 1, IsArea = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001",
|
||||
BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver",
|
||||
BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false
|
||||
}
|
||||
};
|
||||
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID",
|
||||
FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath",
|
||||
FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber",
|
||||
FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 3, TagName = "TestMachine_001", AttributeName = "BatchItems",
|
||||
FullTagReference = "TestMachine_001.BatchItems[]", MxDataType = 5, IsArray = true,
|
||||
ArrayDimension = 50
|
||||
}
|
||||
};
|
||||
|
||||
return (hierarchy, attributes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that object and variable counts are computed correctly from the seeded Galaxy model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_CreatesCorrectNodeCounts()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.ObjectCount.ShouldBe(2); // TestMachine_001, DelmiaReceiver
|
||||
model.VariableCount.ShouldBe(4); // MachineID, DownloadPath, JobStepNumber, BatchItems
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that runtime tag references are populated for every published variable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_TagReferencesPopulated()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that array attributes are represented in the tag-reference map.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_ArrayVariable_HasCorrectInfo()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true);
|
||||
model.NodeIdToTagReference["TestMachine_001.BatchItems"].ShouldBe("TestMachine_001.BatchItems[]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that Galaxy areas are not counted as object nodes in the resulting model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_Areas_AreNotCountedAsObjects()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true },
|
||||
new() { GobjectId = 2, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 1, IsArea = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, new List<GalaxyAttributeInfo>());
|
||||
model.ObjectCount.ShouldBe(1); // Only Obj1, not Area1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that only top-level Galaxy nodes are returned as roots in the model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_RootNodes_AreTopLevel()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Root1", BrowseName = "Root1", ParentGobjectId = 0, IsArea = true },
|
||||
new() { GobjectId = 2, TagName = "Child1", BrowseName = "Child1", ParentGobjectId = 1, IsArea = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, new List<GalaxyAttributeInfo>());
|
||||
model.RootNodes.Count.ShouldBe(1); // Only Root1 is a root
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that variables for multiple MX data types are included in the model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildAddressSpace_DataTypeMappings()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Obj", BrowseName = "Obj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "BoolAttr", FullTagReference = "Obj.BoolAttr",
|
||||
MxDataType = 1, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "IntAttr", FullTagReference = "Obj.IntAttr",
|
||||
MxDataType = 2, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "FloatAttr", FullTagReference = "Obj.FloatAttr",
|
||||
MxDataType = 3, IsArray = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj", AttributeName = "StrAttr", FullTagReference = "Obj.StrAttr",
|
||||
MxDataType = 5, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
model.VariableCount.ShouldBe(4);
|
||||
model.NodeIdToTagReference.Count.ShouldBe(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies rebuild behavior by comparing address-space models before and after metadata changes.
|
||||
/// </summary>
|
||||
public class LmxNodeManagerRebuildTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that rebuilding with new metadata replaces the old tag-reference set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_NewBuild_ReplacesOldData()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "OldObj", BrowseName = "OldObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs1 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "OldObj", AttributeName = "OldAttr", FullTagReference = "OldObj.OldAttr",
|
||||
MxDataType = 5, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model1 = AddressSpaceBuilder.Build(hierarchy1, attrs1);
|
||||
model1.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(true);
|
||||
|
||||
// Rebuild with new data
|
||||
var hierarchy2 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 2, TagName = "NewObj", BrowseName = "NewObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs2 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "NewObj", AttributeName = "NewAttr", FullTagReference = "NewObj.NewAttr",
|
||||
MxDataType = 2, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model2 = AddressSpaceBuilder.Build(hierarchy2, attrs2);
|
||||
|
||||
// Old nodes not in new model, new nodes present
|
||||
model2.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(false);
|
||||
model2.NodeIdToTagReference.ContainsKey("NewObj.NewAttr").ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that object counts are recalculated from the latest rebuild input.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_UpdatesNodeCounts()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false },
|
||||
new() { GobjectId = 2, TagName = "Obj2", BrowseName = "Obj2", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var model1 = AddressSpaceBuilder.Build(hierarchy1, new List<GalaxyAttributeInfo>());
|
||||
model1.ObjectCount.ShouldBe(2);
|
||||
|
||||
var hierarchy2 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 3, TagName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var model2 = AddressSpaceBuilder.Build(hierarchy2, new List<GalaxyAttributeInfo>());
|
||||
model2.ObjectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that empty metadata produces an empty address-space model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmptyHierarchy_ProducesEmptyModel()
|
||||
{
|
||||
var model = AddressSpaceBuilder.Build(new List<GalaxyObjectInfo>(), new List<GalaxyAttributeInfo>());
|
||||
model.RootNodes.ShouldBeEmpty();
|
||||
model.NodeIdToTagReference.ShouldBeEmpty();
|
||||
model.ObjectCount.ShouldBe(0);
|
||||
model.VariableCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that subscription and unsubscription failures in the MXAccess client
|
||||
/// are handled gracefully by the node manager instead of silently lost.
|
||||
/// </summary>
|
||||
public class LmxNodeManagerSubscriptionFaultTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that a faulted SubscribeAsync is caught and logged rather than silently discarded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("COM connection lost")
|
||||
};
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
|
||||
// SubscribeTag should catch the fault — not throw and not hang
|
||||
Should.NotThrow(() => nodeManager.SubscribeTag("TestMachine_001.MachineID"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a faulted UnsubscribeAsync is caught and logged rather than silently discarded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnsubscribeTag_WhenClientFaults_DoesNotThrowAndDoesNotHang()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
|
||||
// Subscribe first (succeeds)
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
// Now inject fault for unsubscribe
|
||||
mxClient.UnsubscribeException = new InvalidOperationException("COM connection lost");
|
||||
|
||||
// UnsubscribeTag should catch the fault — not throw and not hang
|
||||
Should.NotThrow(() => nodeManager.UnsubscribeTag("TestMachine_001.MachineID"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that subscription failure does not corrupt the ref-count bookkeeping,
|
||||
/// allowing a retry to succeed after the fault clears.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeTag_AfterFaultClears_CanSubscribeAgain()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient
|
||||
{
|
||||
SubscribeException = new InvalidOperationException("transient fault")
|
||||
};
|
||||
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient);
|
||||
await fixture.InitializeAsync();
|
||||
try
|
||||
{
|
||||
var nodeManager = fixture.Service.NodeManagerInstance!;
|
||||
|
||||
// First subscribe faults (caught)
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(0); // subscribe failed
|
||||
|
||||
// Clear the fault
|
||||
mxClient.SubscribeException = null;
|
||||
|
||||
// Unsubscribe to reset ref count, then subscribe again
|
||||
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
|
||||
nodeManager.SubscribeTag("TestMachine_001.MachineID");
|
||||
mxClient.ActiveSubscriptionCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.OpcUa
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies translation between bridge quality values and OPC UA status codes.
|
||||
/// </summary>
|
||||
public class OpcUaQualityMapperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that good bridge quality maps to an OPC UA good status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Good_MapsToGoodStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Good);
|
||||
StatusCode.IsGood(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that bad bridge quality maps to an OPC UA bad status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Bad_MapsToBadStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Bad);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that uncertain bridge quality maps to an OPC UA uncertain status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Uncertain_MapsToUncertainStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Uncertain);
|
||||
StatusCode.IsUncertain(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that communication failures map to a bad OPC UA status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BadCommFailure_MapsCorrectly()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.BadCommFailure);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the OPC UA good status maps back to bridge good quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromStatusCode_Good()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Good);
|
||||
q.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the OPC UA bad status maps back to bridge bad quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromStatusCode_Bad()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Bad);
|
||||
q.ShouldBe(Quality.Bad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the OPC UA uncertain status maps back to bridge uncertain quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromStatusCode_Uncertain()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Uncertain);
|
||||
q.ShouldBe(Quality.Uncertain);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Redundancy
|
||||
{
|
||||
public class RedundancyConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConfig_Disabled()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.Enabled.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_ModeWarm()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.Mode.ShouldBe("Warm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_RolePrimary()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.Role.ShouldBe("Primary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_EmptyServerUris()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.ServerUris.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_ServiceLevelBase200()
|
||||
{
|
||||
var config = new RedundancyConfiguration();
|
||||
config.ServiceLevelBase.ShouldBe(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Redundancy
|
||||
{
|
||||
public class RedundancyModeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_Disabled_ReturnsNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Warm", false).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Warm_ReturnsWarm()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Warm", true).ShouldBe(RedundancySupport.Warm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Hot_ReturnsHot()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Hot", true).ShouldBe(RedundancySupport.Hot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Unknown_FallsBackToNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("Transparent", true).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CaseInsensitive()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("warm", true).ShouldBe(RedundancySupport.Warm);
|
||||
RedundancyModeResolver.Resolve("WARM", true).ShouldBe(RedundancySupport.Warm);
|
||||
RedundancyModeResolver.Resolve("hot", true).ShouldBe(RedundancySupport.Hot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Null_FallsBackToNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve(null!, true).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Empty_FallsBackToNone()
|
||||
{
|
||||
RedundancyModeResolver.Resolve("", true).ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Redundancy
|
||||
{
|
||||
public class ServiceLevelCalculatorTests
|
||||
{
|
||||
private readonly ServiceLevelCalculator _calculator = new();
|
||||
|
||||
[Fact]
|
||||
public void FullyHealthy_Primary_ReturnsBase()
|
||||
{
|
||||
_calculator.Calculate(200, true, true).ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullyHealthy_Secondary_ReturnsBaseMinusFifty()
|
||||
{
|
||||
_calculator.Calculate(150, true, true).ShouldBe((byte)150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MxAccessDown_ReducesServiceLevel()
|
||||
{
|
||||
_calculator.Calculate(200, false, true).ShouldBe((byte)100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DbDown_ReducesServiceLevel()
|
||||
{
|
||||
_calculator.Calculate(200, true, false).ShouldBe((byte)150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothDown_ReturnsZero()
|
||||
{
|
||||
_calculator.Calculate(200, false, false).ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampedTo255()
|
||||
{
|
||||
_calculator.Calculate(255, true, true).ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampedToZero()
|
||||
{
|
||||
_calculator.Calculate(50, false, true).ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroBase_BothHealthy_ReturnsZero()
|
||||
{
|
||||
_calculator.Calculate(0, true, true).ShouldBe((byte)0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder unit test that keeps the unit test project wired into the solution.
|
||||
/// </summary>
|
||||
public class SampleTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the unit test assembly is executing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Placeholder_ShouldPass()
|
||||
{
|
||||
true.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Security
|
||||
{
|
||||
public class SecurityProfileConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConfig_HasNoneProfile()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.Profiles.ShouldContain("None");
|
||||
config.Profiles.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_AutoAcceptTrue()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.AutoAcceptClientCertificates.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_RejectSha1True()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.RejectSHA1Certificates.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_MinKeySize2048()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.MinimumCertificateKeySize.ShouldBe(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_PkiRootPathNull()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.PkiRootPath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConfig_CertificateSubjectNull()
|
||||
{
|
||||
var config = new SecurityProfileConfiguration();
|
||||
config.CertificateSubject.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Security
|
||||
{
|
||||
public class SecurityProfileResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_DefaultNone_ReturnsSingleNonePolicy()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "None" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SignProfile_ReturnsBasic256Sha256Sign()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "Basic256Sha256-Sign" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.Sign);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_SignAndEncryptProfile_ReturnsBasic256Sha256SignAndEncrypt()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "Basic256Sha256-SignAndEncrypt" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_MultipleProfiles_ReturnsExpectedPolicies()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>
|
||||
{
|
||||
"None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt"
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.SignAndEncrypt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_DuplicateProfiles_Deduplicated()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>
|
||||
{
|
||||
"None", "None", "Basic256Sha256-Sign", "Basic256Sha256-Sign"
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnknownProfile_SkippedWithWarning()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>
|
||||
{
|
||||
"None", "SomeUnknownProfile"
|
||||
});
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EmptyList_FallsBackToNone()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string>());
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NullList_FallsBackToNone()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(null!);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_AllUnknownProfiles_FallsBackToNone()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "Bogus", "AlsoBogus" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_CaseInsensitive()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "none", "BASIC256SHA256-SIGN" });
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None);
|
||||
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WhitespaceEntries_Skipped()
|
||||
{
|
||||
var result = SecurityProfileResolver.Resolve(new List<string> { "", " ", "None" });
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidProfileNames_ContainsExpectedEntries()
|
||||
{
|
||||
var names = SecurityProfileResolver.ValidProfileNames;
|
||||
|
||||
names.ShouldContain("None");
|
||||
names.ShouldContain("Basic256Sha256-Sign");
|
||||
names.ShouldContain("Basic256Sha256-SignAndEncrypt");
|
||||
names.ShouldContain("Aes128_Sha256_RsaOaep-Sign");
|
||||
names.ShouldContain("Aes128_Sha256_RsaOaep-SignAndEncrypt");
|
||||
names.ShouldContain("Aes256_Sha256_RsaPss-Sign");
|
||||
names.ShouldContain("Aes256_Sha256_RsaPss-SignAndEncrypt");
|
||||
names.Count.ShouldBe(7);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Status;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies how the dashboard health service classifies bridge health from connection state and metrics.
|
||||
/// </summary>
|
||||
public class HealthCheckServiceTests
|
||||
{
|
||||
private readonly HealthCheckService _sut = new();
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a disconnected runtime is reported as unhealthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NotConnected_ReturnsUnhealthy()
|
||||
{
|
||||
var result = _sut.CheckHealth(ConnectionState.Disconnected, null);
|
||||
result.Status.ShouldBe("Unhealthy");
|
||||
result.Color.ShouldBe("red");
|
||||
result.Message.ShouldContain("not connected");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a connected runtime with no metrics history is still considered healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Connected_NoMetrics_ReturnsHealthy()
|
||||
{
|
||||
var result = _sut.CheckHealth(ConnectionState.Connected, null);
|
||||
result.Status.ShouldBe("Healthy");
|
||||
result.Color.ShouldBe("green");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that good success-rate metrics keep the service in a healthy state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Connected_GoodMetrics_ReturnsHealthy()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
for (var i = 0; i < 200; i++)
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10));
|
||||
|
||||
var result = _sut.CheckHealth(ConnectionState.Connected, metrics);
|
||||
result.Status.ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that poor operation success rates degrade the reported health state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Connected_LowSuccessRate_ReturnsDegraded()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
for (var i = 0; i < 40; i++)
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10));
|
||||
for (var i = 0; i < 80; i++)
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), false);
|
||||
|
||||
var result = _sut.CheckHealth(ConnectionState.Connected, metrics);
|
||||
result.Status.ShouldBe("Degraded");
|
||||
result.Color.ShouldBe("yellow");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the boolean health helper reports true when the runtime is connected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsHealthy_Connected_ReturnsTrue()
|
||||
{
|
||||
_sut.IsHealthy(ConnectionState.Connected, null).ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the boolean health helper reports false when the runtime is disconnected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsHealthy_Disconnected_ReturnsFalse()
|
||||
{
|
||||
_sut.IsHealthy(ConnectionState.Disconnected, null).ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the error connection state is treated as unhealthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Error_ReturnsUnhealthy()
|
||||
{
|
||||
var result = _sut.CheckHealth(ConnectionState.Error, null);
|
||||
result.Status.ShouldBe("Unhealthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the reconnecting state is treated as unhealthy while recovery is in progress.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Reconnecting_ReturnsUnhealthy()
|
||||
{
|
||||
var result = _sut.CheckHealth(ConnectionState.Reconnecting, null);
|
||||
result.Status.ShouldBe("Unhealthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historian enabled but plugin failed to load → Degraded with the plugin error in the message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistorianEnabled_PluginLoadFailed_ReturnsDegraded()
|
||||
{
|
||||
var historian = new HistorianStatusInfo
|
||||
{
|
||||
Enabled = true,
|
||||
PluginStatus = "LoadFailed",
|
||||
PluginError = "aahClientManaged.dll could not be loaded"
|
||||
};
|
||||
|
||||
var result = _sut.CheckHealth(ConnectionState.Connected, null, historian);
|
||||
|
||||
result.Status.ShouldBe("Degraded");
|
||||
result.Color.ShouldBe("yellow");
|
||||
result.Message.ShouldContain("LoadFailed");
|
||||
result.Message.ShouldContain("aahClientManaged.dll");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historian disabled is healthy regardless of plugin status string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistorianDisabled_ReturnsHealthy()
|
||||
{
|
||||
var historian = new HistorianStatusInfo
|
||||
{
|
||||
Enabled = false,
|
||||
PluginStatus = "Disabled"
|
||||
};
|
||||
|
||||
_sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historian enabled and plugin loaded is healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistorianEnabled_PluginLoaded_ReturnsHealthy()
|
||||
{
|
||||
var historian = new HistorianStatusInfo { Enabled = true, PluginStatus = "Loaded" };
|
||||
_sut.CheckHealth(ConnectionState.Connected, null, historian).Status.ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HistoryRead operations degrade after only 11 samples with <50% success rate
|
||||
/// (lower threshold than the regular 100-sample rule).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistoryReadLowSuccessRate_WithLowSampleCount_ReturnsDegraded()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
for (var i = 0; i < 4; i++)
|
||||
metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10));
|
||||
for (var i = 0; i < 8; i++)
|
||||
metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10), false);
|
||||
|
||||
var result = _sut.CheckHealth(ConnectionState.Connected, metrics);
|
||||
|
||||
result.Status.ShouldBe("Degraded");
|
||||
result.Message.ShouldContain("HistoryReadRaw");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A HistoryRead sample under the 10-sample threshold does not degrade the service.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistoryReadLowSuccessRate_BelowThreshold_ReturnsHealthy()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
for (var i = 0; i < 5; i++)
|
||||
metrics.RecordOperation("HistoryReadRaw", TimeSpan.FromMilliseconds(10), false);
|
||||
|
||||
_sut.CheckHealth(ConnectionState.Connected, metrics).Status.ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alarm acknowledge write failures are latched — any non-zero count degrades the service.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmAckWriteFailures_AnyCount_ReturnsDegraded()
|
||||
{
|
||||
var alarms = new AlarmStatusInfo { TrackingEnabled = true, AckWriteFailures = 1 };
|
||||
|
||||
var result = _sut.CheckHealth(ConnectionState.Connected, null, null, alarms);
|
||||
|
||||
result.Status.ShouldBe("Degraded");
|
||||
result.Message.ShouldContain("Alarm acknowledge");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alarm tracking disabled ignores any failure count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmAckWriteFailures_TrackingDisabled_ReturnsHealthy()
|
||||
{
|
||||
var alarms = new AlarmStatusInfo { TrackingEnabled = false, AckWriteFailures = 99 };
|
||||
|
||||
_sut.CheckHealth(ConnectionState.Connected, null, null, alarms).Status.ShouldBe("Healthy");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Status;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the HTML, JSON, and health snapshots generated for the operator status dashboard.
|
||||
/// </summary>
|
||||
public class StatusReportServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the generated HTML contains every dashboard panel expected by operators.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_ContainsAllPanels()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
|
||||
html.ShouldContain("Connection");
|
||||
html.ShouldContain("Health");
|
||||
html.ShouldContain("Subscriptions");
|
||||
html.ShouldContain("Galaxy Info");
|
||||
html.ShouldContain("Operations");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the generated HTML includes the configured auto-refresh meta tag.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_ContainsMetaRefresh()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
html.ShouldContain("meta http-equiv='refresh' content='10'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the connection panel renders the current runtime connection state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_ConnectionPanel_ShowsState()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
html.ShouldContain("Connected");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the Galaxy panel renders the bridged Galaxy name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_GalaxyPanel_ShowsName()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
html.ShouldContain("TestGalaxy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the operations table renders the expected performance metric headers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_OperationsTable_ShowsHeaders()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
html.ShouldContain("Count");
|
||||
html.ShouldContain("Success Rate");
|
||||
html.ShouldContain("Avg (ms)");
|
||||
html.ShouldContain("Min (ms)");
|
||||
html.ShouldContain("Max (ms)");
|
||||
html.ShouldContain("P95 (ms)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard title shows the service version inline so operators can identify the deployed
|
||||
/// build without scrolling, and the standalone footer panel is gone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_Title_ShowsVersion_NoFooter()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
|
||||
html.ShouldContain("<h1>LmxOpcUa Status Dashboard");
|
||||
html.ShouldContain("class='version'");
|
||||
html.ShouldNotContain("<h2>Footer</h2>");
|
||||
html.ShouldNotContain("Generated:");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the generated JSON includes the major dashboard sections.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Deserializes()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldNotBeNullOrWhiteSpace();
|
||||
json.ShouldContain("Connection");
|
||||
json.ShouldContain("Health");
|
||||
json.ShouldContain("Subscriptions");
|
||||
json.ShouldContain("Galaxy");
|
||||
json.ShouldContain("Operations");
|
||||
json.ShouldContain("Historian");
|
||||
json.ShouldContain("Alarms");
|
||||
json.ShouldContain("Footer");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON exposes the historian plugin status so operators can distinguish
|
||||
/// "disabled by config" from "plugin crashed on load."
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Historian_IncludesPluginStatus()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("PluginStatus");
|
||||
json.ShouldContain("PluginPath");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON exposes alarm counters so operators can see transition/ack activity.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Alarms_IncludesCounters()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("TrackingEnabled");
|
||||
json.ShouldContain("TransitionCount");
|
||||
json.ShouldContain("AckWriteFailures");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Historian and Alarms panels render in the HTML dashboard.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_IncludesHistorianAndAlarmPanels()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
|
||||
html.ShouldContain("<h2>Historian</h2>");
|
||||
html.ShouldContain("<h2>Alarms</h2>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Endpoints panel renders in the HTML dashboard even when no server host has been set,
|
||||
/// so operators can tell the OPC UA server has not started.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_IncludesEndpointsPanel()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
|
||||
html.ShouldContain("<h2>Endpoints</h2>");
|
||||
html.ShouldContain("OPC UA server not started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON surfaces the alarm filter counters so monitoring clients can verify scope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Alarms_IncludesFilterCounters()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("FilterEnabled");
|
||||
json.ShouldContain("FilterPatternCount");
|
||||
json.ShouldContain("FilterIncludedObjectCount");
|
||||
json.ShouldContain("FilterPatterns");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no filter configured, the Alarms panel renders an explicit "disabled" line so operators
|
||||
/// know all alarm-bearing objects are being tracked.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateHtml_AlarmsPanel_FilterDisabled_ShowsDisabledLine()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHtml();
|
||||
|
||||
html.ShouldContain("Filter: <b>disabled</b>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard JSON surfaces the Endpoints section with base-address and security-profile slots
|
||||
/// so monitoring clients can read them programmatically.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateJson_Endpoints_IncludesBaseAddressesAndSecurityProfiles()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateJson();
|
||||
|
||||
json.ShouldContain("Endpoints");
|
||||
json.ShouldContain("BaseAddresses");
|
||||
json.ShouldContain("SecurityProfiles");
|
||||
json.ShouldContain("UserTokenPolicies");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The /api/health payload exposes Historian and Alarms component status.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetHealthData_Components_IncludeHistorianAndAlarms()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.Components.Historian.ShouldNotBeNullOrEmpty();
|
||||
data.Components.Alarms.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the report service reports healthy when the runtime connection is up.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsHealthy_WhenConnected_ReturnsTrue()
|
||||
{
|
||||
var sut = CreateService();
|
||||
sut.IsHealthy().ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the report service reports unhealthy when the runtime connection is down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsHealthy_WhenDisconnected_ReturnsFalse()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient { State = ConnectionState.Disconnected };
|
||||
var sut = new StatusReportService(new HealthCheckService(), 10);
|
||||
sut.SetComponents(mxClient, null, null, null);
|
||||
sut.IsHealthy().ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthData_WhenConnected_ReturnsHealthyStatus()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.Status.ShouldBe("Healthy");
|
||||
data.Components.MxAccess.ShouldBe("Connected");
|
||||
data.Components.Database.ShouldBe("Connected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthData_WhenDisconnected_ReturnsUnhealthyStatus()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient { State = ConnectionState.Disconnected };
|
||||
var galaxyStats = new GalaxyRepositoryStats { DbConnected = false };
|
||||
var sut = new StatusReportService(new HealthCheckService(), 10);
|
||||
sut.SetComponents(mxClient, null, galaxyStats, null);
|
||||
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.Status.ShouldBe("Unhealthy");
|
||||
data.ServiceLevel.ShouldBe((byte)0);
|
||||
data.Components.MxAccess.ShouldBe("Disconnected");
|
||||
data.Components.Database.ShouldBe("Disconnected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthData_NoRedundancy_ServiceLevel255WhenHealthy()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.RedundancyEnabled.ShouldBe(false);
|
||||
data.ServiceLevel.ShouldBe((byte)255);
|
||||
data.RedundancyRole.ShouldBeNull();
|
||||
data.RedundancyMode.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthData_WithRedundancy_IncludesRoleAndServiceLevel()
|
||||
{
|
||||
var sut = CreateServiceWithRedundancy("Primary");
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.RedundancyEnabled.ShouldBe(true);
|
||||
data.RedundancyRole.ShouldBe("Primary");
|
||||
data.RedundancyMode.ShouldBe("Warm");
|
||||
data.ServiceLevel.ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthData_SecondaryRole_LowerServiceLevel()
|
||||
{
|
||||
var sut = CreateServiceWithRedundancy("Secondary");
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.ServiceLevel.ShouldBe((byte)150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthData_ContainsUptime()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.Uptime.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthData_ContainsTimestamp()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var data = sut.GetHealthData();
|
||||
|
||||
data.Timestamp.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateHealthJson_ContainsExpectedFields()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var json = sut.GenerateHealthJson();
|
||||
|
||||
json.ShouldContain("Status");
|
||||
json.ShouldContain("ServiceLevel");
|
||||
json.ShouldContain("Components");
|
||||
json.ShouldContain("MxAccess");
|
||||
json.ShouldContain("Database");
|
||||
json.ShouldContain("OpcUaServer");
|
||||
json.ShouldContain("Uptime");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateHealthHtml_ContainsStatusBadge()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHealthHtml();
|
||||
|
||||
html.ShouldContain("HEALTHY");
|
||||
html.ShouldContain("SERVICE LEVEL");
|
||||
html.ShouldContain("255");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateHealthHtml_ContainsComponentCards()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHealthHtml();
|
||||
|
||||
html.ShouldContain("MXAccess");
|
||||
html.ShouldContain("Galaxy Database");
|
||||
html.ShouldContain("OPC UA Server");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateHealthHtml_WithRedundancy_ShowsRoleAndMode()
|
||||
{
|
||||
var sut = CreateServiceWithRedundancy("Primary");
|
||||
var html = sut.GenerateHealthHtml();
|
||||
|
||||
html.ShouldContain("Primary");
|
||||
html.ShouldContain("Warm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateHealthHtml_ContainsAutoRefresh()
|
||||
{
|
||||
var sut = CreateService();
|
||||
var html = sut.GenerateHealthHtml();
|
||||
html.ShouldContain("meta http-equiv='refresh' content='10'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a status report service preloaded with representative runtime, Galaxy, and metrics data.
|
||||
/// </summary>
|
||||
/// <returns>A configured status report service for dashboard assertions.</returns>
|
||||
private static StatusReportService CreateService()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10));
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20));
|
||||
|
||||
var galaxyStats = new GalaxyRepositoryStats
|
||||
{
|
||||
GalaxyName = "TestGalaxy",
|
||||
DbConnected = true,
|
||||
LastDeployTime = new DateTime(2024, 6, 1),
|
||||
ObjectCount = 42,
|
||||
AttributeCount = 200,
|
||||
LastRebuildTime = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var sut = new StatusReportService(new HealthCheckService(), 10);
|
||||
sut.SetComponents(mxClient, metrics, galaxyStats, null);
|
||||
return sut;
|
||||
}
|
||||
|
||||
private static StatusReportService CreateServiceWithRedundancy(string role)
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
var galaxyStats = new GalaxyRepositoryStats { GalaxyName = "TestGalaxy", DbConnected = true };
|
||||
var redundancyConfig = new RedundancyConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Mode = "Warm",
|
||||
Role = role,
|
||||
ServiceLevelBase = 200
|
||||
};
|
||||
var sut = new StatusReportService(new HealthCheckService(), 10);
|
||||
sut.SetComponents(mxClient, null, galaxyStats, null, null, redundancyConfig, "urn:test:instance1");
|
||||
return sut;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Status;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the lightweight HTTP dashboard host that exposes bridge status to operators.
|
||||
/// </summary>
|
||||
public class StatusWebServerTests : IDisposable
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly int _port;
|
||||
private readonly StatusWebServer _server;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a status web server on a random test port and prepares an HTTP client for endpoint assertions.
|
||||
/// </summary>
|
||||
public StatusWebServerTests()
|
||||
{
|
||||
_port = new Random().Next(18000, 19000);
|
||||
var reportService = new StatusReportService(new HealthCheckService(), 10);
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
reportService.SetComponents(mxClient, null, null, null);
|
||||
_server = new StatusWebServer(reportService, _port);
|
||||
_server.Start();
|
||||
_client = new HttpClient { BaseAddress = new Uri($"http://localhost:{_port}") };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the test HTTP client and stops the status web server.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the dashboard root responds with HTML content.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Root_ReturnsHtml200()
|
||||
{
|
||||
var response = await _client.GetAsync("/");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the JSON status endpoint responds successfully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApiStatus_ReturnsJson200()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the health endpoint returns HTTP 200 when the bridge is healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApiHealth_Returns200WhenHealthy()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/health");
|
||||
// FakeMxAccessClient starts as Connected → healthy
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
body.ShouldContain("healthy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unknown dashboard routes return HTTP 404.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnknownPath_Returns404()
|
||||
{
|
||||
var response = await _client.GetAsync("/unknown");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that unsupported HTTP methods are rejected with HTTP 405.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PostMethod_Returns405()
|
||||
{
|
||||
var response = await _client.PostAsync("/", new StringContent(""));
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that Start() returns false and logs a failure when the target port is
|
||||
/// already bound by another listener. Regression guard for the stability-review 2026-04-13
|
||||
/// Finding 2: OpcUaService now surfaces this return value into DashboardStartFailed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Start_WhenPortInUse_ReturnsFalse()
|
||||
{
|
||||
var port = new Random().Next(19000, 19500);
|
||||
using var blocker = new HttpListener();
|
||||
blocker.Prefixes.Add($"http://localhost:{port}/");
|
||||
blocker.Start();
|
||||
|
||||
var reportService = new StatusReportService(new HealthCheckService(), 10);
|
||||
reportService.SetComponents(new FakeMxAccessClient(), null, null, null);
|
||||
using var contested = new StatusWebServer(reportService, port);
|
||||
|
||||
contested.Start().ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that cache-control headers disable caching for dashboard responses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CacheHeaders_Present()
|
||||
{
|
||||
var response = await _client.GetAsync("/");
|
||||
response.Headers.CacheControl?.NoCache.ShouldBe(true);
|
||||
response.Headers.CacheControl?.NoStore.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the /health route returns an HTML health page.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HealthPage_ReturnsHtml200()
|
||||
{
|
||||
var response = await _client.GetAsync("/health");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
body.ShouldContain("SERVICE LEVEL");
|
||||
body.ShouldContain("MXAccess");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that /api/health returns rich JSON with component health details.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApiHealth_ReturnsRichJson()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/health");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
body.ShouldContain("ServiceLevel");
|
||||
body.ShouldContain("Components");
|
||||
body.ShouldContain("Uptime");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that the server can be started and stopped cleanly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StartStop_DoesNotThrow()
|
||||
{
|
||||
var server2 = new StatusWebServer(
|
||||
new StatusReportService(new HealthCheckService(), 10),
|
||||
new Random().Next(19000, 20000));
|
||||
server2.Start().ShouldBe(true);
|
||||
server2.IsRunning.ShouldBe(true);
|
||||
server2.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Utilities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Utilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the bounded sync-over-async wrapper introduced by stability review 2026-04-13
|
||||
/// Finding 3. The wrapper is a backstop applied at every LmxNodeManager sync-over-async site
|
||||
/// (Read, Write, HistoryRead*, BuildAddressSpace probe sync).
|
||||
/// </summary>
|
||||
public class SyncOverAsyncTests
|
||||
{
|
||||
[Fact]
|
||||
public void WaitSync_CompletedTask_ReturnsResult()
|
||||
{
|
||||
var task = Task.FromResult(42);
|
||||
SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "test").ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitSync_CompletedNonGenericTask_Returns()
|
||||
{
|
||||
var task = Task.CompletedTask;
|
||||
Should.NotThrow(() => SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "test"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitSync_NeverCompletingTask_ThrowsTimeoutException()
|
||||
{
|
||||
var tcs = new TaskCompletionSource<int>();
|
||||
var ex = Should.Throw<TimeoutException>(() =>
|
||||
SyncOverAsync.WaitSync(tcs.Task, TimeSpan.FromMilliseconds(100), "op"));
|
||||
ex.Message.ShouldContain("op");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitSync_NeverCompletingNonGenericTask_ThrowsTimeoutException()
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
Should.Throw<TimeoutException>(() =>
|
||||
SyncOverAsync.WaitSync((Task)tcs.Task, TimeSpan.FromMilliseconds(100), "op"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitSync_FaultedNonGenericTask_UnwrapsInnerException()
|
||||
{
|
||||
var task = Task.FromException(new InvalidOperationException("boom"));
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "op"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitSync_FaultedGenericTask_UnwrapsInnerException()
|
||||
{
|
||||
var task = Task.FromException<int>(new InvalidOperationException("boom"));
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
SyncOverAsync.WaitSync(task, TimeSpan.FromSeconds(1), "op"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitSync_NullTask_ThrowsArgumentNullException()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
SyncOverAsync.WaitSync((Task)null!, TimeSpan.FromSeconds(1), "op"));
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
SyncOverAsync.WaitSync((Task<int>)null!, TimeSpan.FromSeconds(1), "op"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: Galaxy change detection → OnGalaxyChanged → address space rebuild
|
||||
/// </summary>
|
||||
public class ChangeDetectionToRebuildWiringTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that a changed deploy timestamp causes the change-detection pipeline to raise another rebuild signal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ChangedTimestamp_TriggersRebuild()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
LastDeployTime = new DateTime(2024, 1, 1),
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new() { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false }
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "Obj1", AttributeName = "Attr1", FullTagReference = "Obj1.Attr1",
|
||||
MxDataType = 5, IsArray = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var rebuildCount = 0;
|
||||
var service = new ChangeDetectionService(repo, 1);
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref rebuildCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500); // First poll triggers
|
||||
rebuildCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Change deploy time → should trigger rebuild
|
||||
repo.LastDeployTime = new DateTime(2024, 2, 1);
|
||||
await Task.Delay(1500);
|
||||
service.Stop();
|
||||
|
||||
rebuildCount.ShouldBeGreaterThanOrEqualTo(2);
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: FakeMxProxy OnDataChange → MxAccessClient → OnTagValueChanged → node manager delivery
|
||||
/// </summary>
|
||||
public class MxAccessToNodeManagerWiringTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that a simulated data change reaches the global tag-value-changed event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DataChange_ReachesGlobalHandler()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
string? receivedAddress = null;
|
||||
Vtq? receivedVtq = null;
|
||||
|
||||
mxClient.OnTagValueChanged += (addr, vtq) =>
|
||||
{
|
||||
receivedAddress = addr;
|
||||
receivedVtq = vtq;
|
||||
};
|
||||
|
||||
mxClient.SimulateDataChange("TestTag.Attr", Vtq.Good(42));
|
||||
|
||||
receivedAddress.ShouldBe("TestTag.Attr");
|
||||
receivedVtq.ShouldNotBeNull();
|
||||
receivedVtq.Value.Value.ShouldBe(42);
|
||||
receivedVtq.Value.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that a simulated data change reaches the stored per-tag subscription callback.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DataChange_ReachesSubscriptionCallback()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
Vtq? received = null;
|
||||
|
||||
await mxClient.SubscribeAsync("TestTag.Attr", (addr, vtq) => received = vtq);
|
||||
mxClient.SimulateDataChange("TestTag.Attr", Vtq.Good(99));
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received.Value.Value.ShouldBe(99);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: OPC UA Read → NodeManager → IMxAccessClient.ReadAsync with correct full_tag_reference
|
||||
/// </summary>
|
||||
public class OpcUaReadToMxAccessWiringTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the resolved OPC UA read path uses the expected full Galaxy tag reference.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Read_ResolvesCorrectTagReference()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["DelmiaReceiver_001.DownloadPath"] = Vtq.Good("/some/path");
|
||||
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 0,
|
||||
IsArea = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver",
|
||||
BrowseName = "DelmiaReceiver", ParentGobjectId = 1, IsArea = false
|
||||
}
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 2, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath",
|
||||
FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
// The model should contain the correct tag reference
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
|
||||
model.NodeIdToTagReference["DelmiaReceiver_001.DownloadPath"].ShouldBe("DelmiaReceiver_001.DownloadPath");
|
||||
|
||||
// The MxAccessClient should be able to read using the tag reference
|
||||
var vtq = await mxClient.ReadAsync("DelmiaReceiver_001.DownloadPath");
|
||||
vtq.Value.ShouldBe("/some/path");
|
||||
vtq.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression for stability review 2026-04-13 Finding 2. Confirms that when the dashboard
|
||||
/// port is already bound, the service continues to start (degraded mode) and the
|
||||
/// <see cref="OpcUaService.DashboardStartFailed"/> flag is raised.
|
||||
/// </summary>
|
||||
public class OpcUaServiceDashboardFailureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Start_DashboardPortInUse_ContinuesInDegradedMode()
|
||||
{
|
||||
var dashboardPort = new Random().Next(19500, 19999);
|
||||
using var blocker = new HttpListener();
|
||||
blocker.Prefixes.Add($"http://localhost:{dashboardPort}/");
|
||||
blocker.Start();
|
||||
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration
|
||||
{
|
||||
Port = 14842,
|
||||
GalaxyName = "TestGalaxy",
|
||||
EndpointPath = "/LmxOpcUa"
|
||||
},
|
||||
MxAccess = new MxAccessConfiguration { ClientName = "Test" },
|
||||
GalaxyRepository = new GalaxyRepositoryConfiguration(),
|
||||
Dashboard = new DashboardConfiguration { Enabled = true, Port = dashboardPort }
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy();
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj",
|
||||
ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr",
|
||||
FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
service.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// Service continues despite dashboard bind failure — degraded mode policy.
|
||||
service.ServerHost.ShouldNotBeNull();
|
||||
service.DashboardStartFailed.ShouldBeTrue();
|
||||
service.StatusWeb.ShouldBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
service.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: OPC UA Write → NodeManager → IMxAccessClient.WriteAsync with correct tag+value
|
||||
/// </summary>
|
||||
public class OpcUaWriteToMxAccessWiringTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that the resolved OPC UA write path targets the expected Galaxy tag reference and payload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SendsCorrectTagAndValue()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 0,
|
||||
IsArea = false
|
||||
}
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestMachine_001", AttributeName = "MachineCode",
|
||||
FullTagReference = "TestMachine_001.MachineCode", MxDataType = 5, IsArray = false
|
||||
}
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
var tagRef = model.NodeIdToTagReference["TestMachine_001.MachineCode"];
|
||||
|
||||
// Write through MxAccessClient
|
||||
var result = await mxClient.WriteAsync(tagRef, "NEW_CODE");
|
||||
|
||||
result.ShouldBe(true);
|
||||
mxClient.WrittenValues.ShouldContain(w =>
|
||||
w.Tag == "TestMachine_001.MachineCode" && (string)w.Value == "NEW_CODE");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: OpcUaService Start() creates and wires all components with fakes.
|
||||
/// </summary>
|
||||
public class ServiceStartupSequenceTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that startup with fake dependencies creates the expected bridge components and state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Start_WithFakes_AllComponentsCreated()
|
||||
{
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration
|
||||
{
|
||||
Port = 14840,
|
||||
GalaxyName = "TestGalaxy",
|
||||
EndpointPath = "/LmxOpcUa"
|
||||
},
|
||||
MxAccess = new MxAccessConfiguration { ClientName = "Test" },
|
||||
GalaxyRepository = new GalaxyRepositoryConfiguration(),
|
||||
Dashboard = new DashboardConfiguration { Enabled = false } // Don't start HTTP listener in tests
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy();
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr",
|
||||
FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
service.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// Verify all components were created
|
||||
service.MxClient.ShouldNotBeNull();
|
||||
service.MxClient!.State.ShouldBe(ConnectionState.Connected);
|
||||
service.Metrics.ShouldNotBeNull();
|
||||
service.ServerHost.ShouldNotBeNull();
|
||||
service.ChangeDetectionInstance.ShouldNotBeNull();
|
||||
service.GalaxyStatsInstance.ShouldNotBeNull();
|
||||
service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy");
|
||||
service.GalaxyStatsInstance.DbConnected.ShouldBe(true);
|
||||
service.StatusReportInstance.ShouldNotBeNull();
|
||||
|
||||
// Dashboard disabled → no web server
|
||||
service.StatusWeb.ShouldBeNull();
|
||||
|
||||
// MxProxy should have been registered
|
||||
proxy.IsRegistered.ShouldBe(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
service.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that when MXAccess is initially unavailable, the background monitor reconnects it later.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Start_WhenMxAccessIsInitiallyDown_MonitorReconnectsInBackground()
|
||||
{
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration
|
||||
{
|
||||
Port = 14841,
|
||||
GalaxyName = "TestGalaxy",
|
||||
EndpointPath = "/LmxOpcUa"
|
||||
},
|
||||
MxAccess = new MxAccessConfiguration
|
||||
{
|
||||
ClientName = "Test",
|
||||
MonitorIntervalSeconds = 1,
|
||||
AutoReconnect = true
|
||||
},
|
||||
GalaxyRepository = new GalaxyRepositoryConfiguration(),
|
||||
Dashboard = new DashboardConfiguration { Enabled = false }
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy { ShouldFailRegister = true };
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false
|
||||
}
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr",
|
||||
FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
service.Start();
|
||||
|
||||
try
|
||||
{
|
||||
service.ServerHost.ShouldNotBeNull();
|
||||
service.MxClient.ShouldNotBeNull();
|
||||
service.MxClient!.State.ShouldBe(ConnectionState.Error);
|
||||
|
||||
proxy.ShouldFailRegister = false;
|
||||
await Task.Delay(2500);
|
||||
|
||||
service.MxClient.State.ShouldBe(ConnectionState.Connected);
|
||||
proxy.RegisterCallCount.ShouldBeGreaterThan(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
service.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: Start then Stop completes within 30 seconds. (SVC-004)
|
||||
/// </summary>
|
||||
public class ShutdownCompletesTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirms that a started service can shut down within the required time budget.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Shutdown_CompletesWithin30Seconds()
|
||||
{
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration { Port = 14841, GalaxyName = "TestGalaxy" },
|
||||
MxAccess = new MxAccessConfiguration { ClientName = "Test" },
|
||||
Dashboard = new DashboardConfiguration { Enabled = false }
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy();
|
||||
var repo = new FakeGalaxyRepository();
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
|
||||
service.Start();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
service.Stop();
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.TotalSeconds.ShouldBeLessThan(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Tests</RootNamespace>
|
||||
<!-- Keep the assembly name unchanged so v1 OtOpcUa.Host's InternalsVisibleTo still matches. -->
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Tests</AssemblyName>
|
||||
<!--
|
||||
Phase 2 Stream D — archived. These 494 v1 IntegrationTests instantiate v1
|
||||
OtOpcUa.Host classes directly. They are kept as the historical parity reference
|
||||
but excluded from full-solution `dotnet test ZB.MOM.WW.OtOpcUa.slnx` so the v2
|
||||
E2E suite (OtOpcUa.Driver.Galaxy.E2E) is the live parity bar going forward.
|
||||
To run them explicitly:
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||
-->
|
||||
<IsTestProject>false</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.2.1"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\src\ZB.MOM.WW.OtOpcUa.Host\appsettings.json" Link="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user