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:
Joseph Doherty
2026-03-25 05:55:27 -04:00
commit a7576ffb38
283 changed files with 16493 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
using ZB.MOM.WW.LmxOpcUa.Host.Status;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
{
public class HealthCheckServiceTests
{
private readonly HealthCheckService _sut = new();
[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");
}
[Fact]
public void Connected_NoMetrics_ReturnsHealthy()
{
var result = _sut.CheckHealth(ConnectionState.Connected, null);
result.Status.ShouldBe("Healthy");
result.Color.ShouldBe("green");
}
[Fact]
public void Connected_GoodMetrics_ReturnsHealthy()
{
using var metrics = new PerformanceMetrics();
for (int i = 0; i < 200; i++)
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
var result = _sut.CheckHealth(ConnectionState.Connected, metrics);
result.Status.ShouldBe("Healthy");
}
[Fact]
public void Connected_LowSuccessRate_ReturnsDegraded()
{
using var metrics = new PerformanceMetrics();
for (int i = 0; i < 40; i++)
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
for (int 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");
}
[Fact]
public void IsHealthy_Connected_ReturnsTrue()
{
_sut.IsHealthy(ConnectionState.Connected, null).ShouldBe(true);
}
[Fact]
public void IsHealthy_Disconnected_ReturnsFalse()
{
_sut.IsHealthy(ConnectionState.Disconnected, null).ShouldBe(false);
}
[Fact]
public void Error_ReturnsUnhealthy()
{
var result = _sut.CheckHealth(ConnectionState.Error, null);
result.Status.ShouldBe("Unhealthy");
}
[Fact]
public void Reconnecting_ReturnsUnhealthy()
{
var result = _sut.CheckHealth(ConnectionState.Reconnecting, null);
result.Status.ShouldBe("Unhealthy");
}
}
}

View File

@@ -0,0 +1,127 @@
using System;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository;
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
using ZB.MOM.WW.LmxOpcUa.Host.Status;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
{
public class StatusReportServiceTests
{
[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");
html.ShouldContain("Footer");
}
[Fact]
public void GenerateHtml_ContainsMetaRefresh()
{
var sut = CreateService();
var html = sut.GenerateHtml();
html.ShouldContain("meta http-equiv='refresh' content='10'");
}
[Fact]
public void GenerateHtml_ConnectionPanel_ShowsState()
{
var sut = CreateService();
var html = sut.GenerateHtml();
html.ShouldContain("Connected");
}
[Fact]
public void GenerateHtml_GalaxyPanel_ShowsName()
{
var sut = CreateService();
var html = sut.GenerateHtml();
html.ShouldContain("TestGalaxy");
}
[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)");
}
[Fact]
public void GenerateHtml_Footer_ContainsTimestampAndVersion()
{
var sut = CreateService();
var html = sut.GenerateHtml();
html.ShouldContain("Generated:");
html.ShouldContain("Version:");
}
[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("Footer");
}
[Fact]
public void IsHealthy_WhenConnected_ReturnsTrue()
{
var sut = CreateService();
sut.IsHealthy().ShouldBe(true);
}
[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);
}
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;
}
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Status;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Status
{
public class StatusWebServerTests : IDisposable
{
private readonly StatusWebServer _server;
private readonly HttpClient _client;
private readonly int _port;
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}") };
}
public void Dispose()
{
_client.Dispose();
_server.Dispose();
}
[Fact]
public async Task Root_ReturnsHtml200()
{
var response = await _client.GetAsync("/");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");
}
[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");
}
[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");
}
[Fact]
public async Task UnknownPath_Returns404()
{
var response = await _client.GetAsync("/unknown");
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
[Fact]
public async Task PostMethod_Returns405()
{
var response = await _client.PostAsync("/", new StringContent(""));
response.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed);
}
[Fact]
public async Task CacheHeaders_Present()
{
var response = await _client.GetAsync("/");
response.Headers.CacheControl?.NoCache.ShouldBe(true);
response.Headers.CacheControl?.NoStore.ShouldBe(true);
}
[Fact]
public void StartStop_DoesNotThrow()
{
var server2 = new StatusWebServer(
new StatusReportService(new HealthCheckService(), 10),
new Random().Next(19000, 20000));
server2.Start();
server2.IsRunning.ShouldBe(true);
server2.Stop();
}
}
}