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