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:
Joseph Doherty
2026-04-18 08:35:22 -04:00
parent 3c1dc334f9
commit dd3a449308
155 changed files with 0 additions and 24774 deletions

View File

@@ -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();
}
}
}

View File

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

View File

@@ -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");
}
}
}

View File

@@ -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:");
}
}
}

View File

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

View File

@@ -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("."));
}
}
}

View File

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

View File

@@ -1,5 +0,0 @@
{
"GalaxyRepository": {
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;"
}
}

View File

@@ -1,4 +0,0 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false
}

View File

@@ -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"
};
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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")
};
}
}

View File

@@ -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("");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}
}

View File

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

View File

@@ -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();
}
}
}

View File

@@ -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));
}
}
}

View File

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

View File

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

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}
}
}

View File

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

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 &lt;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");
}
}
}

View File

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

View File

@@ -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();
}
}
}

View File

@@ -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"));
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}
}

View File

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