Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*

Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.

Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.

Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 13:57:47 -04:00
parent 5b8d708c58
commit 3b2defd94f
293 changed files with 841 additions and 722 deletions

View File

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,97 @@
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

@@ -0,0 +1,185 @@
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

@@ -0,0 +1,210 @@
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

@@ -0,0 +1,195 @@
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

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,287 @@
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

@@ -0,0 +1,120 @@
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
}
};
}
}
}