Implement LmxOpcUa server — all 6 phases complete
Full OPC UA server on .NET Framework 4.8 (x86) exposing AVEVA System Platform Galaxy tags via MXAccess. Mirrors Galaxy object hierarchy as OPC UA address space, translating contained-name browse paths to tag-name runtime references. Components implemented: - Configuration: AppConfiguration with 4 sections, validator - Domain: ConnectionState, Quality, Vtq, MxDataTypeMapper, error codes - MxAccess: StaComThread, MxAccessClient (partial classes), MxProxyAdapter using strongly-typed ArchestrA.MxAccess COM interop - Galaxy Repository: SQL queries (hierarchy, attributes, change detection), ChangeDetectionService with auto-rebuild on deploy - OPC UA Server: LmxNodeManager (CustomNodeManager2), LmxOpcUaServer, OpcUaServerHost with programmatic config, SecurityPolicy None - Status Dashboard: HTTP server with HTML/JSON/health endpoints - Integration: Full 14-step startup, graceful shutdown, component wiring 175 tests (174 unit + 1 integration), all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
|
||||
{
|
||||
public class MxAccessClientConnectionTests : IDisposable
|
||||
{
|
||||
private readonly StaComThread _staThread;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly MxAccessClient _client;
|
||||
private readonly List<(ConnectionState Previous, ConnectionState Current)> _stateChanges = new();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialState_IsDisconnected()
|
||||
{
|
||||
_client.State.ShouldBe(ConnectionState.Disconnected);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_RegistersCalled()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
_proxy.RegisterCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnect_UnregistersCalled()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.DisconnectAsync();
|
||||
_proxy.UnregisterCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectFails_TransitionsToError()
|
||||
{
|
||||
_proxy.ShouldFailRegister = true;
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(_client.ConnectAsync());
|
||||
_client.State.ShouldBe(ConnectionState.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoubleConnect_NoOp()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.ConnectAsync(); // Should be no-op
|
||||
_proxy.RegisterCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
|
||||
{
|
||||
public class MxAccessClientMonitorTests : IDisposable
|
||||
{
|
||||
private readonly StaComThread _staThread;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
|
||||
public MxAccessClientMonitorTests()
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_proxy = new FakeMxProxy();
|
||||
_metrics = new PerformanceMetrics();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
|
||||
{
|
||||
public class MxAccessClientReadWriteTests : IDisposable
|
||||
{
|
||||
private readonly StaComThread _staThread;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly MxAccessClient _client;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_NotConnected_ReturnsBad()
|
||||
{
|
||||
var result = await _client.ReadAsync("Tag.Attr");
|
||||
result.Quality.ShouldBe(Quality.BadNotConnected);
|
||||
}
|
||||
|
||||
[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, 192);
|
||||
|
||||
var result = await readTask;
|
||||
result.Value.ShouldBe(42);
|
||||
result.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_NotConnected_ReturnsFalse()
|
||||
{
|
||||
var result = await _client.WriteAsync("Tag.Attr", 42);
|
||||
result.ShouldBe(false);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_RecordsMetrics()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
|
||||
var readTask = _client.ReadAsync("TestTag.Attr");
|
||||
await Task.Delay(50);
|
||||
_proxy.SimulateDataChangeByAddress("TestTag.Attr", 1, 192);
|
||||
await readTask;
|
||||
|
||||
var stats = _metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Read");
|
||||
stats["Read"].TotalCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
|
||||
{
|
||||
public class MxAccessClientSubscriptionTests : IDisposable
|
||||
{
|
||||
private readonly StaComThread _staThread;
|
||||
private readonly FakeMxProxy _proxy;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly MxAccessClient _client;
|
||||
|
||||
public MxAccessClientSubscriptionTests()
|
||||
{
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
_proxy = new FakeMxProxy();
|
||||
_metrics = new PerformanceMetrics();
|
||||
_client = new MxAccessClient(_staThread, _proxy, new MxAccessConfiguration(), _metrics);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_staThread.Dispose();
|
||||
_metrics.Dispose();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_RemovesItemAndUnadvises()
|
||||
{
|
||||
await _client.ConnectAsync();
|
||||
await _client.SubscribeAsync("TestTag.Attr", (_, _) => { });
|
||||
await _client.UnsubscribeAsync("TestTag.Attr");
|
||||
|
||||
_client.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[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, 192);
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received.Value.Value.ShouldBe(42);
|
||||
received.Value.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
[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", 192);
|
||||
|
||||
globalAddr.ShouldBe("TestTag.Attr");
|
||||
}
|
||||
|
||||
[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", 192);
|
||||
callbackInvoked.ShouldBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
tests/ZB.MOM.WW.LmxOpcUa.Tests/MxAccess/StaComThreadTests.cs
Normal file
74
tests/ZB.MOM.WW.LmxOpcUa.Tests/MxAccess/StaComThreadTests.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.MxAccess
|
||||
{
|
||||
public class StaComThreadTests : IDisposable
|
||||
{
|
||||
private readonly StaComThread _thread;
|
||||
|
||||
public StaComThreadTests()
|
||||
{
|
||||
_thread = new StaComThread();
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public void Dispose() => _thread.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ExecutesOnStaThread()
|
||||
{
|
||||
var apartmentState = await _thread.RunAsync(() => Thread.CurrentThread.GetApartmentState());
|
||||
apartmentState.ShouldBe(ApartmentState.STA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Action_Completes()
|
||||
{
|
||||
var executed = false;
|
||||
await _thread.RunAsync(() => executed = true);
|
||||
executed.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Func_ReturnsResult()
|
||||
{
|
||||
var result = await _thread.RunAsync(() => 42);
|
||||
result.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_PropagatesException()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
_thread.RunAsync(() => throw new InvalidOperationException("test error")));
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleWorkItems_ExecuteInOrder()
|
||||
{
|
||||
var results = new System.Collections.Concurrent.ConcurrentBag<int>();
|
||||
await Task.WhenAll(
|
||||
_thread.RunAsync(() => results.Add(1)),
|
||||
_thread.RunAsync(() => results.Add(2)),
|
||||
_thread.RunAsync(() => results.Add(3)));
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user