diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs
index b775e14..25fcd3a 100644
--- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs
+++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs
@@ -22,11 +22,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
private readonly AppConfiguration _config;
private readonly IMxProxy? _mxProxy;
private readonly IGalaxyRepository? _galaxyRepository;
+ private readonly IMxAccessClient? _mxAccessClientOverride;
+ private readonly bool _hasMxAccessClientOverride;
private CancellationTokenSource? _cts;
private PerformanceMetrics? _metrics;
private StaComThread? _staThread;
private MxAccessClient? _mxAccessClient;
+ private IMxAccessClient? _mxAccessClientForWiring;
private ChangeDetectionService? _changeDetection;
private OpcUaServerHost? _serverHost;
private LmxNodeManager? _nodeManager;
@@ -59,11 +62,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
///
/// Test constructor. Accepts injected dependencies.
///
- internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository)
+ internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
+ IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false)
{
_config = config;
_mxProxy = mxProxy;
_galaxyRepository = galaxyRepository;
+ _mxAccessClientOverride = mxAccessClientOverride;
+ _hasMxAccessClientOverride = hasMxAccessClientOverride;
}
public void Start()
@@ -87,7 +93,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
_metrics = new PerformanceMetrics();
// Step 5: Create MxAccessClient → Connect
- if (_mxProxy != null)
+ if (_hasMxAccessClientOverride)
+ {
+ // Test path: use injected IMxAccessClient directly (skips STA thread + COM)
+ _mxAccessClientForWiring = _mxAccessClientOverride;
+ if (_mxAccessClientForWiring != null && _mxAccessClientForWiring.State != ConnectionState.Connected)
+ {
+ _mxAccessClientForWiring.ConnectAsync(_cts.Token).GetAwaiter().GetResult();
+ }
+ }
+ else if (_mxProxy != null)
{
try
{
@@ -121,8 +136,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
}
// Step 8: Create OPC UA server host + node manager
- IMxAccessClient mxClient = _mxAccessClient ?? (IMxAccessClient)new NullMxAccessClient();
- _serverHost = new OpcUaServerHost(_config.OpcUa, mxClient, _metrics);
+ var effectiveMxClient = (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring ?? new NullMxAccessClient();
+ _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics);
// Step 9-10: Query hierarchy, start server, build address space
if (_galaxyRepository != null && _galaxyStats.DbConnected)
@@ -170,7 +185,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
// Step 13: Dashboard
_healthCheck = new HealthCheckService();
_statusReport = new StatusReportService(_healthCheck, _config.Dashboard.RefreshIntervalSeconds);
- _statusReport.SetComponents(_mxAccessClient, _metrics, _galaxyStats, _serverHost);
+ _statusReport.SetComponents(effectiveMxClient, _metrics, _galaxyStats, _serverHost);
if (_config.Dashboard.Enabled)
{
@@ -253,7 +268,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
}
// Accessors for testing
- internal IMxAccessClient? MxClient => _mxAccessClient;
+ internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
internal PerformanceMetrics? Metrics => _metrics;
internal OpcUaServerHost? ServerHost => _serverHost;
internal LmxNodeManager? NodeManagerInstance => _nodeManager;
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs
new file mode 100644
index 0000000..f728386
--- /dev/null
+++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs
@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
+using ZB.MOM.WW.LmxOpcUa.Host.Domain;
+
+namespace ZB.MOM.WW.LmxOpcUa.Host
+{
+ ///
+ /// Fluent builder for constructing OpcUaService with dependency overrides.
+ /// Used by integration tests to substitute fakes for COM/DB components.
+ ///
+ internal class OpcUaServiceBuilder
+ {
+ private AppConfiguration _config = new AppConfiguration();
+ private IMxProxy? _mxProxy;
+ private IGalaxyRepository? _galaxyRepository;
+ private IMxAccessClient? _mxAccessClient;
+ private bool _mxProxySet;
+ private bool _galaxyRepositorySet;
+ private bool _mxAccessClientSet;
+
+ public OpcUaServiceBuilder WithConfig(AppConfiguration config)
+ {
+ _config = config;
+ return this;
+ }
+
+ public OpcUaServiceBuilder WithOpcUaPort(int port)
+ {
+ _config.OpcUa.Port = port;
+ return this;
+ }
+
+ public OpcUaServiceBuilder WithGalaxyName(string name)
+ {
+ _config.OpcUa.GalaxyName = name;
+ return this;
+ }
+
+ public OpcUaServiceBuilder WithMxProxy(IMxProxy? proxy)
+ {
+ _mxProxy = proxy;
+ _mxProxySet = true;
+ return this;
+ }
+
+ public OpcUaServiceBuilder WithGalaxyRepository(IGalaxyRepository? repository)
+ {
+ _galaxyRepository = repository;
+ _galaxyRepositorySet = true;
+ return this;
+ }
+
+ ///
+ /// Override the MxAccessClient directly, skipping STA thread and COM interop entirely.
+ /// When set, the service will use this client instead of creating one from IMxProxy.
+ ///
+ public OpcUaServiceBuilder WithMxAccessClient(IMxAccessClient? client)
+ {
+ _mxAccessClient = client;
+ _mxAccessClientSet = true;
+ return this;
+ }
+
+ public OpcUaServiceBuilder WithHierarchy(List hierarchy, List attributes)
+ {
+ if (!_galaxyRepositorySet)
+ {
+ var fake = new FakeBuilderGalaxyRepository();
+ _galaxyRepository = fake;
+ _galaxyRepositorySet = true;
+ }
+
+ if (_galaxyRepository is FakeBuilderGalaxyRepository fakeRepo)
+ {
+ fakeRepo.Hierarchy = hierarchy;
+ fakeRepo.Attributes = attributes;
+ }
+
+ return this;
+ }
+
+ public OpcUaServiceBuilder DisableDashboard()
+ {
+ _config.Dashboard.Enabled = false;
+ return this;
+ }
+
+ public OpcUaServiceBuilder DisableChangeDetection()
+ {
+ _config.GalaxyRepository.ChangeDetectionIntervalSeconds = int.MaxValue;
+ return this;
+ }
+
+ public OpcUaService Build()
+ {
+ return new OpcUaService(
+ _config,
+ _mxProxySet ? _mxProxy : null,
+ _galaxyRepositorySet ? _galaxyRepository : null,
+ _mxAccessClientSet ? _mxAccessClient : null,
+ _mxAccessClientSet);
+ }
+
+ ///
+ /// Internal fake repository used by WithHierarchy for convenience.
+ ///
+ private class FakeBuilderGalaxyRepository : IGalaxyRepository
+ {
+ public event System.Action? OnGalaxyChanged;
+ public List Hierarchy { get; set; } = new();
+ public List Attributes { get; set; } = new();
+
+ public System.Threading.Tasks.Task> GetHierarchyAsync(System.Threading.CancellationToken ct = default)
+ => System.Threading.Tasks.Task.FromResult(Hierarchy);
+ public System.Threading.Tasks.Task> GetAttributesAsync(System.Threading.CancellationToken ct = default)
+ => System.Threading.Tasks.Task.FromResult(Attributes);
+ public System.Threading.Tasks.Task GetLastDeployTimeAsync(System.Threading.CancellationToken ct = default)
+ => System.Threading.Tasks.Task.FromResult(System.DateTime.UtcNow);
+ public System.Threading.Tasks.Task TestConnectionAsync(System.Threading.CancellationToken ct = default)
+ => System.Threading.Tasks.Task.FromResult(true);
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs
new file mode 100644
index 0000000..c66dee5
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs
@@ -0,0 +1,103 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Host;
+
+namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
+{
+ ///
+ /// 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();
+ ///
+ internal class OpcUaServerFixture : IAsyncLifetime
+ {
+ private static int _nextPort = 16000;
+
+ public OpcUaService Service { get; private set; } = null!;
+ public int OpcUaPort { get; }
+ public string EndpointUrl => $"opc.tcp://localhost:{OpcUaPort}/LmxOpcUa";
+
+ private readonly OpcUaServiceBuilder _builder;
+ private bool _started;
+
+ public OpcUaServerFixture(OpcUaServiceBuilder builder)
+ {
+ OpcUaPort = Interlocked.Increment(ref _nextPort);
+ _builder = builder;
+ _builder.WithOpcUaPort(OpcUaPort);
+ _builder.DisableDashboard();
+ }
+
+ ///
+ /// Creates fixture with FakeMxProxy + FakeGalaxyRepository (standard test data).
+ /// The STA thread and COM interop run against FakeMxProxy.
+ ///
+ 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);
+ }
+
+ ///
+ /// Creates fixture using FakeMxAccessClient directly — skips STA thread + COM entirely.
+ /// Fastest option for tests that don't need real COM interop.
+ ///
+ public static OpcUaServerFixture WithFakeMxAccessClient(
+ FakeMxAccessClient? mxClient = null,
+ FakeGalaxyRepository? repo = 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");
+
+ return new OpcUaServerFixture(builder);
+ }
+
+ public Task InitializeAsync()
+ {
+ Service = _builder.Build();
+ Service.Start();
+ _started = true;
+ return Task.CompletedTask;
+ }
+
+ public Task DisposeAsync()
+ {
+ if (_started)
+ {
+ try { Service.Stop(); }
+ catch { /* swallow cleanup errors */ }
+ }
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixtureTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixtureTests.cs
new file mode 100644
index 0000000..7ca4187
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixtureTests.cs
@@ -0,0 +1,86 @@
+using System.Diagnostics;
+using System.Threading.Tasks;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.LmxOpcUa.Host.Domain;
+
+namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
+{
+ public class OpcUaServerFixtureTests
+ {
+ [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();
+ }
+
+ [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();
+ }
+
+ [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();
+ }
+
+ [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);
+ }
+
+ [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();
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/TestData.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/TestData.cs
new file mode 100644
index 0000000..5a78985
--- /dev/null
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/TestData.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+using ZB.MOM.WW.LmxOpcUa.Host.Domain;
+
+namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
+{
+ ///
+ /// Reusable test data matching the Galaxy hierarchy from gr/layout.md.
+ ///
+ public static class TestData
+ {
+ public static List CreateStandardHierarchy()
+ {
+ return new List
+ {
+ new GalaxyObjectInfo { GobjectId = 1, TagName = "DEV", ContainedName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, IsArea = true },
+ new GalaxyObjectInfo { GobjectId = 2, TagName = "TestArea", ContainedName = "TestArea", BrowseName = "TestArea", ParentGobjectId = 1, IsArea = true },
+ new GalaxyObjectInfo { GobjectId = 3, TagName = "TestMachine_001", ContainedName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 2, IsArea = false },
+ new GalaxyObjectInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", BrowseName = "DelmiaReceiver", ParentGobjectId = 3, IsArea = false },
+ new GalaxyObjectInfo { GobjectId = 5, TagName = "MESReceiver_001", ContainedName = "MESReceiver", BrowseName = "MESReceiver", ParentGobjectId = 3, IsArea = false },
+ };
+ }
+
+ public static List CreateStandardAttributes()
+ {
+ return new List
+ {
+ new GalaxyAttributeInfo { GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID", FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false },
+ new GalaxyAttributeInfo { GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineCode", FullTagReference = "TestMachine_001.MachineCode", MxDataType = 5, IsArray = false },
+ new GalaxyAttributeInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false },
+ new GalaxyAttributeInfo { GobjectId = 4, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber", FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false },
+ new GalaxyAttributeInfo { GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInBatchID", FullTagReference = "MESReceiver_001.MoveInBatchID", MxDataType = 5, IsArray = false },
+ new GalaxyAttributeInfo { GobjectId = 5, TagName = "MESReceiver_001", AttributeName = "MoveInPartNumbers", FullTagReference = "MESReceiver_001.MoveInPartNumbers[]", MxDataType = 5, IsArray = true, ArrayDimension = 50 },
+ };
+ }
+
+ public static List CreateMinimalHierarchy()
+ {
+ return new List
+ {
+ new GalaxyObjectInfo { GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false }
+ };
+ }
+
+ public static List CreateMinimalAttributes()
+ {
+ return new List
+ {
+ new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr", FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false }
+ };
+ }
+ }
+}