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,14 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
|
||||
{
|
||||
public class SampleIntegrationTest
|
||||
{
|
||||
[Fact]
|
||||
public void Placeholder_ShouldPass()
|
||||
{
|
||||
true.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.LmxOpcUa.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxOpcUa.Host\ZB.MOM.WW.LmxOpcUa.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.test.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="xunit.runner.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"GalaxyRepository": {
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=true;"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
|
||||
{
|
||||
public class ConfigurationLoadingTests
|
||||
{
|
||||
private static AppConfiguration LoadFromJson()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.Build();
|
||||
|
||||
var config = new AppConfiguration();
|
||||
configuration.GetSection("OpcUa").Bind(config.OpcUa);
|
||||
configuration.GetSection("MxAccess").Bind(config.MxAccess);
|
||||
configuration.GetSection("GalaxyRepository").Bind(config.GalaxyRepository);
|
||||
configuration.GetSection("Dashboard").Bind(config.Dashboard);
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUa_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.OpcUa.Port.ShouldBe(4840);
|
||||
config.OpcUa.EndpointPath.ShouldBe("/LmxOpcUa");
|
||||
config.OpcUa.ServerName.ShouldBe("LmxOpcUa");
|
||||
config.OpcUa.GalaxyName.ShouldBe("ZB");
|
||||
config.OpcUa.MaxSessions.ShouldBe(100);
|
||||
config.OpcUa.SessionTimeoutMinutes.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MxAccess_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
|
||||
config.MxAccess.ReadTimeoutSeconds.ShouldBe(5);
|
||||
config.MxAccess.WriteTimeoutSeconds.ShouldBe(5);
|
||||
config.MxAccess.MaxConcurrentOperations.ShouldBe(10);
|
||||
config.MxAccess.MonitorIntervalSeconds.ShouldBe(5);
|
||||
config.MxAccess.AutoReconnect.ShouldBe(true);
|
||||
config.MxAccess.ProbeStaleThresholdSeconds.ShouldBe(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GalaxyRepository_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.GalaxyRepository.ConnectionString.ShouldContain("ZB");
|
||||
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
|
||||
config.GalaxyRepository.CommandTimeoutSeconds.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dashboard_Section_BindsCorrectly()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
config.Dashboard.Enabled.ShouldBe(true);
|
||||
config.Dashboard.Port.ShouldBe(8081);
|
||||
config.Dashboard.RefreshIntervalSeconds.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.OpcUa.Port.ShouldBe(4840);
|
||||
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
|
||||
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
|
||||
config.Dashboard.Enabled.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_ValidConfig_ReturnsTrue()
|
||||
{
|
||||
var config = LoadFromJson();
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_InvalidPort_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.OpcUa.Port = 0;
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_EmptyGalaxyName_ReturnsFalse()
|
||||
{
|
||||
var config = new AppConfiguration();
|
||||
config.OpcUa.GalaxyName = "";
|
||||
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
|
||||
{
|
||||
public class MxDataTypeMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, 1u)] // Boolean
|
||||
[InlineData(2, 6u)] // Integer → Int32
|
||||
[InlineData(3, 10u)] // Float
|
||||
[InlineData(4, 11u)] // Double
|
||||
[InlineData(5, 12u)] // String
|
||||
[InlineData(6, 13u)] // DateTime
|
||||
[InlineData(7, 11u)] // ElapsedTime → Double
|
||||
[InlineData(8, 12u)] // Reference → String
|
||||
[InlineData(13, 6u)] // Enumeration → Int32
|
||||
[InlineData(14, 12u)] // Custom → String
|
||||
[InlineData(15, 21u)] // InternationalizedString → LocalizedText
|
||||
[InlineData(16, 12u)] // Custom → String
|
||||
public void MapToOpcUaDataType_AllKnownTypes(int mxDataType, uint expectedNodeId)
|
||||
{
|
||||
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(expectedNodeId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(99)]
|
||||
[InlineData(-1)]
|
||||
public void MapToOpcUaDataType_UnknownDefaultsToString(int mxDataType)
|
||||
{
|
||||
MxDataTypeMapper.MapToOpcUaDataType(mxDataType).ShouldBe(12u); // String
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, typeof(bool))]
|
||||
[InlineData(2, typeof(int))]
|
||||
[InlineData(3, typeof(float))]
|
||||
[InlineData(4, typeof(double))]
|
||||
[InlineData(5, typeof(string))]
|
||||
[InlineData(6, typeof(DateTime))]
|
||||
[InlineData(7, typeof(double))]
|
||||
[InlineData(8, typeof(string))]
|
||||
[InlineData(13, typeof(int))]
|
||||
[InlineData(15, typeof(string))]
|
||||
public void MapToClrType_AllKnownTypes(int mxDataType, Type expectedType)
|
||||
{
|
||||
MxDataTypeMapper.MapToClrType(mxDataType).ShouldBe(expectedType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToClrType_UnknownDefaultsToString()
|
||||
{
|
||||
MxDataTypeMapper.MapToClrType(999).ShouldBe(typeof(string));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpcUaTypeName_Boolean()
|
||||
{
|
||||
MxDataTypeMapper.GetOpcUaTypeName(1).ShouldBe("Boolean");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpcUaTypeName_Unknown_ReturnsString()
|
||||
{
|
||||
MxDataTypeMapper.GetOpcUaTypeName(999).ShouldBe("String");
|
||||
}
|
||||
}
|
||||
}
|
||||
46
tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/MxErrorCodesTests.cs
Normal file
46
tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/MxErrorCodesTests.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
|
||||
{
|
||||
public class MxErrorCodesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1008, "Invalid reference")]
|
||||
[InlineData(1012, "Wrong data type")]
|
||||
[InlineData(1013, "Not writable")]
|
||||
[InlineData(1014, "Request timed out")]
|
||||
[InlineData(1015, "Communication failure")]
|
||||
[InlineData(1016, "Not connected")]
|
||||
public void GetMessage_KnownCodes_ContainsDescription(int code, string expectedSubstring)
|
||||
{
|
||||
MxErrorCodes.GetMessage(code).ShouldContain(expectedSubstring);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMessage_UnknownCode_ReturnsUnknown()
|
||||
{
|
||||
MxErrorCodes.GetMessage(9999).ShouldContain("Unknown");
|
||||
MxErrorCodes.GetMessage(9999).ShouldContain("9999");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1008, Quality.BadConfigError)]
|
||||
[InlineData(1012, Quality.BadConfigError)]
|
||||
[InlineData(1013, Quality.BadOutOfService)]
|
||||
[InlineData(1014, Quality.BadCommFailure)]
|
||||
[InlineData(1015, Quality.BadCommFailure)]
|
||||
[InlineData(1016, Quality.BadNotConnected)]
|
||||
public void MapToQuality_KnownCodes(int code, Quality expected)
|
||||
{
|
||||
MxErrorCodes.MapToQuality(code).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToQuality_UnknownCode_ReturnsBad()
|
||||
{
|
||||
MxErrorCodes.MapToQuality(9999).ShouldBe(Quality.Bad);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/QualityMapperTests.cs
Normal file
101
tests/ZB.MOM.WW.LmxOpcUa.Tests/Domain/QualityMapperTests.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
|
||||
{
|
||||
public class QualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, Quality.Bad)]
|
||||
[InlineData(4, Quality.BadConfigError)]
|
||||
[InlineData(20, Quality.BadCommFailure)]
|
||||
[InlineData(32, Quality.BadWaitingForInitialData)]
|
||||
public void MapFromMxAccess_BadFamily(int input, Quality expected)
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(64, Quality.Uncertain)]
|
||||
[InlineData(68, Quality.UncertainLastUsable)]
|
||||
[InlineData(88, Quality.UncertainSubNormal)]
|
||||
public void MapFromMxAccess_UncertainFamily(int input, Quality expected)
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(192, Quality.Good)]
|
||||
[InlineData(216, Quality.GoodLocalOverride)]
|
||||
public void MapFromMxAccess_GoodFamily(int input, Quality expected)
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapFromMxAccess_UnknownBadValue_ReturnsBad()
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(63).ShouldBe(Quality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapFromMxAccess_UnknownUncertainValue_ReturnsUncertain()
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(100).ShouldBe(Quality.Uncertain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapFromMxAccess_UnknownGoodValue_ReturnsGood()
|
||||
{
|
||||
QualityMapper.MapFromMxAccessQuality(200).ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToOpcUa_Good_Returns0()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.Good).ShouldBe(0x00000000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToOpcUa_Bad_Returns80000000()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.Bad).ShouldBe(0x80000000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToOpcUa_BadCommFailure()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.BadCommFailure).ShouldBe(0x80050000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapToOpcUa_Uncertain()
|
||||
{
|
||||
QualityMapper.MapToOpcUaStatusCode(Quality.Uncertain).ShouldBe(0x40000000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualityExtensions_IsGood()
|
||||
{
|
||||
Quality.Good.IsGood().ShouldBe(true);
|
||||
Quality.Good.IsBad().ShouldBe(false);
|
||||
Quality.Good.IsUncertain().ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualityExtensions_IsBad()
|
||||
{
|
||||
Quality.Bad.IsBad().ShouldBe(true);
|
||||
Quality.Bad.IsGood().ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualityExtensions_IsUncertain()
|
||||
{
|
||||
Quality.Uncertain.IsUncertain().ShouldBe(true);
|
||||
Quality.Uncertain.IsGood().ShouldBe(false);
|
||||
Quality.Uncertain.IsBad().ShouldBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
tests/ZB.MOM.WW.LmxOpcUa.Tests/EndToEnd/FullDataFlowTest.cs
Normal file
103
tests/ZB.MOM.WW.LmxOpcUa.Tests/EndToEnd/FullDataFlowTest.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.EndToEnd
|
||||
{
|
||||
/// <summary>
|
||||
/// THE ULTIMATE SMOKE TEST: Full service with fakes, verifying the complete data flow.
|
||||
/// (1) Address space built, (2) MXAccess data change → callback, (3) read → correct tag ref,
|
||||
/// (4) write → correct tag+value, (5) dashboard has real data.
|
||||
/// </summary>
|
||||
public class FullDataFlowTest
|
||||
{
|
||||
[Fact]
|
||||
public void FullDataFlow_EndToEnd()
|
||||
{
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration { Port = 14842, GalaxyName = "TestGalaxy", EndpointPath = "/LmxOpcUa" },
|
||||
MxAccess = new MxAccessConfiguration { ClientName = "Test", ReadTimeoutSeconds = 2, WriteTimeoutSeconds = 2 },
|
||||
GalaxyRepository = new GalaxyRepositoryConfiguration { ChangeDetectionIntervalSeconds = 60 },
|
||||
Dashboard = new DashboardConfiguration { Enabled = false }
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy();
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "DEV", BrowseName = "DEV", ParentGobjectId = 0, IsArea = true },
|
||||
new GalaxyObjectInfo { GobjectId = 2, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 1, IsArea = false },
|
||||
new GalaxyObjectInfo { GobjectId = 3, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", BrowseName = "DelmiaReceiver", ParentGobjectId = 2, IsArea = false }
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 2, TagName = "TestMachine_001", AttributeName = "MachineID", FullTagReference = "TestMachine_001.MachineID", MxDataType = 5, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 3, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 3, TagName = "DelmiaReceiver_001", AttributeName = "JobStepNumber", FullTagReference = "DelmiaReceiver_001.JobStepNumber", MxDataType = 2, IsArray = false }
|
||||
}
|
||||
};
|
||||
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
service.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// (1) OPC UA server host created
|
||||
service.ServerHost.ShouldNotBeNull();
|
||||
|
||||
// (2) MXAccess connected and proxy registered
|
||||
proxy.IsRegistered.ShouldBe(true);
|
||||
service.MxClient.ShouldNotBeNull();
|
||||
service.MxClient!.State.ShouldBe(ConnectionState.Connected);
|
||||
|
||||
// (3) Address space model can be built from the same data
|
||||
var model = Host.OpcUa.AddressSpaceBuilder.Build(repo.Hierarchy, repo.Attributes);
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true);
|
||||
model.VariableCount.ShouldBe(3);
|
||||
model.ObjectCount.ShouldBe(2); // TestMachine + DelmiaReceiver (DEV is area)
|
||||
|
||||
// (4) Tag reference resolves correctly for read/write
|
||||
var tagRef = model.NodeIdToTagReference["DelmiaReceiver_001.DownloadPath"];
|
||||
tagRef.ShouldBe("DelmiaReceiver_001.DownloadPath");
|
||||
|
||||
// (5) Galaxy stats have real data
|
||||
service.GalaxyStatsInstance.ShouldNotBeNull();
|
||||
service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy");
|
||||
service.GalaxyStatsInstance.DbConnected.ShouldBe(true);
|
||||
service.GalaxyStatsInstance.ObjectCount.ShouldBe(3);
|
||||
service.GalaxyStatsInstance.AttributeCount.ShouldBe(3);
|
||||
|
||||
// (5b) Status report has real data
|
||||
service.StatusReportInstance.ShouldNotBeNull();
|
||||
var html = service.StatusReportInstance!.GenerateHtml();
|
||||
html.ShouldContain("TestGalaxy");
|
||||
html.ShouldContain("Connected");
|
||||
|
||||
var json = service.StatusReportInstance.GenerateJson();
|
||||
json.ShouldContain("TestGalaxy");
|
||||
|
||||
service.StatusReportInstance.IsHealthy().ShouldBe(true);
|
||||
|
||||
// Verify change detection is wired
|
||||
service.ChangeDetectionInstance.ShouldNotBeNull();
|
||||
|
||||
// Verify metrics created
|
||||
service.Metrics.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
service.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.GalaxyRepository
|
||||
{
|
||||
public class ChangeDetectionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FirstPoll_AlwaysTriggers()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, intervalSeconds: 1);
|
||||
var triggered = false;
|
||||
service.OnGalaxyChanged += () => triggered = true;
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500);
|
||||
service.Stop();
|
||||
|
||||
triggered.ShouldBe(true);
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameTimestamp_DoesNotTriggerAgain()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, intervalSeconds: 1);
|
||||
var triggerCount = 0;
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(2500); // Should have polled at least twice
|
||||
service.Stop();
|
||||
|
||||
triggerCount.ShouldBe(1); // Only the first poll
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangedTimestamp_TriggersAgain()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, intervalSeconds: 1);
|
||||
var triggerCount = 0;
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Change the deploy time
|
||||
repo.LastDeployTime = new DateTime(2024, 2, 1);
|
||||
await Task.Delay(1500);
|
||||
service.Stop();
|
||||
|
||||
triggerCount.ShouldBeGreaterThanOrEqualTo(2);
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedPoll_DoesNotCrash_RetriesNext()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository { LastDeployTime = new DateTime(2024, 1, 1) };
|
||||
var service = new ChangeDetectionService(repo, intervalSeconds: 1);
|
||||
var triggerCount = 0;
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref triggerCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500);
|
||||
|
||||
// Make it fail
|
||||
repo.ShouldThrow = true;
|
||||
await Task.Delay(1500);
|
||||
|
||||
// Restore and it should recover
|
||||
repo.ShouldThrow = false;
|
||||
repo.LastDeployTime = new DateTime(2024, 3, 1);
|
||||
await Task.Delay(1500);
|
||||
service.Stop();
|
||||
|
||||
// Should have triggered at least on first poll and on the changed timestamp
|
||||
triggerCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_BeforeStart_DoesNotThrow()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository();
|
||||
var service = new ChangeDetectionService(repo, intervalSeconds: 30);
|
||||
service.Stop(); // Should not throw
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
{
|
||||
public class FakeGalaxyRepository : IGalaxyRepository
|
||||
{
|
||||
public event Action? OnGalaxyChanged;
|
||||
|
||||
public List<GalaxyObjectInfo> Hierarchy { get; set; } = new List<GalaxyObjectInfo>();
|
||||
public List<GalaxyAttributeInfo> Attributes { get; set; } = new List<GalaxyAttributeInfo>();
|
||||
public DateTime? LastDeployTime { get; set; } = DateTime.UtcNow;
|
||||
public bool ConnectionSucceeds { get; set; } = true;
|
||||
public bool ShouldThrow { get; set; }
|
||||
|
||||
public Task<List<GalaxyObjectInfo>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(Hierarchy);
|
||||
}
|
||||
|
||||
public Task<List<GalaxyAttributeInfo>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(Attributes);
|
||||
}
|
||||
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(LastDeployTime);
|
||||
}
|
||||
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ShouldThrow) throw new Exception("Simulated DB failure");
|
||||
return Task.FromResult(ConnectionSucceeds);
|
||||
}
|
||||
|
||||
public void RaiseGalaxyChanged() => OnGalaxyChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
76
tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxAccessClient.cs
Normal file
76
tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxAccessClient.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
{
|
||||
public class FakeMxAccessClient : IMxAccessClient
|
||||
{
|
||||
public ConnectionState State { get; set; } = ConnectionState.Connected;
|
||||
public int ActiveSubscriptionCount => _subscriptions.Count;
|
||||
public int ReconnectCount { get; set; }
|
||||
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
public event Action<string, Vtq>? OnTagValueChanged;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Action<string, Vtq>> _subscriptions = new(StringComparer.OrdinalIgnoreCase);
|
||||
public ConcurrentDictionary<string, Vtq> TagValues { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<(string Tag, object Value)> WrittenValues { get; } = new();
|
||||
public bool WriteResult { get; set; } = true;
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
State = ConnectionState.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DisconnectAsync()
|
||||
{
|
||||
State = ConnectionState.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SubscribeAsync(string fullTagReference, Action<string, Vtq> callback)
|
||||
{
|
||||
_subscriptions[fullTagReference] = callback;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(string fullTagReference)
|
||||
{
|
||||
_subscriptions.TryRemove(fullTagReference, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Vtq> ReadAsync(string fullTagReference, CancellationToken ct = default)
|
||||
{
|
||||
if (TagValues.TryGetValue(fullTagReference, out var vtq))
|
||||
return Task.FromResult(vtq);
|
||||
return Task.FromResult(Vtq.Bad(Quality.BadNotConnected));
|
||||
}
|
||||
|
||||
public Task<bool> WriteAsync(string fullTagReference, object value, CancellationToken ct = default)
|
||||
{
|
||||
WrittenValues.Add((fullTagReference, value));
|
||||
return Task.FromResult(WriteResult);
|
||||
}
|
||||
|
||||
public void SimulateDataChange(string address, Vtq vtq)
|
||||
{
|
||||
OnTagValueChanged?.Invoke(address, vtq);
|
||||
if (_subscriptions.TryGetValue(address, out var callback))
|
||||
callback(address, vtq);
|
||||
}
|
||||
|
||||
public void RaiseConnectionStateChanged(ConnectionState prev, ConnectionState curr)
|
||||
{
|
||||
State = curr;
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(prev, curr));
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
119
tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxProxy.cs
Normal file
119
tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeMxProxy.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using ArchestrA.MxAccess;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.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 _nextHandle = 1;
|
||||
private int _connectionHandle;
|
||||
private bool _registered;
|
||||
|
||||
public event MxDataChangeHandler? OnDataChange;
|
||||
public event MxWriteCompleteHandler? OnWriteComplete;
|
||||
|
||||
public ConcurrentDictionary<int, string> Items { get; } = new ConcurrentDictionary<int, string>();
|
||||
public ConcurrentDictionary<int, bool> AdvisedItems { get; } = new ConcurrentDictionary<int, bool>();
|
||||
public List<(string Address, object Value)> WrittenValues { get; } = new List<(string, object)>();
|
||||
|
||||
public bool IsRegistered => _registered;
|
||||
public int RegisterCallCount { get; private set; }
|
||||
public int UnregisterCallCount { get; private set; }
|
||||
public bool ShouldFailRegister { get; set; }
|
||||
public bool ShouldFailWrite { get; set; }
|
||||
public int WriteCompleteStatus { get; set; } = 0; // 0 = success
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
RegisterCallCount++;
|
||||
if (ShouldFailRegister) throw new InvalidOperationException("Register failed (simulated)");
|
||||
_registered = true;
|
||||
_connectionHandle = Interlocked.Increment(ref _nextHandle);
|
||||
return _connectionHandle;
|
||||
}
|
||||
|
||||
public void Unregister(int handle)
|
||||
{
|
||||
UnregisterCallCount++;
|
||||
_registered = false;
|
||||
_connectionHandle = 0;
|
||||
}
|
||||
|
||||
public int AddItem(int handle, string address)
|
||||
{
|
||||
var itemHandle = Interlocked.Increment(ref _nextHandle);
|
||||
Items[itemHandle] = address;
|
||||
return itemHandle;
|
||||
}
|
||||
|
||||
public void RemoveItem(int handle, int itemHandle)
|
||||
{
|
||||
Items.TryRemove(itemHandle, out _);
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(int handle, int itemHandle)
|
||||
{
|
||||
AdvisedItems[itemHandle] = true;
|
||||
}
|
||||
|
||||
public void UnAdviseSupervisory(int handle, int itemHandle)
|
||||
{
|
||||
AdvisedItems.TryRemove(itemHandle, out _);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
OnWriteComplete?.Invoke(_connectionHandle, itemHandle, ref status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates an MXAccess data change event for a specific item handle.
|
||||
/// </summary>
|
||||
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,
|
||||
(object)(timestamp ?? DateTime.UtcNow), ref status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates data change for a specific address (finds handle by address).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Metrics
|
||||
{
|
||||
public class PerformanceMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyState_ReturnsZeroStatistics()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordOperation_TracksCounts()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10), true);
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(20), false);
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Read");
|
||||
stats["Read"].TotalCount.ShouldBe(2);
|
||||
stats["Read"].SuccessCount.ShouldBe(1);
|
||||
stats["Read"].SuccessRate.ShouldBe(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordOperation_TracksMinMaxAverage()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(10));
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(30));
|
||||
metrics.RecordOperation("Write", TimeSpan.FromMilliseconds(20));
|
||||
|
||||
var stats = metrics.GetStatistics()["Write"];
|
||||
stats.MinMilliseconds.ShouldBe(10);
|
||||
stats.MaxMilliseconds.ShouldBe(30);
|
||||
stats.AverageMilliseconds.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void P95_CalculatedCorrectly()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
for (int i = 1; i <= 100; i++)
|
||||
metrics.RecordOperation("Op", TimeSpan.FromMilliseconds(i));
|
||||
|
||||
var stats = metrics.GetStatistics()["Op"];
|
||||
stats.Percentile95Milliseconds.ShouldBe(95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollingBuffer_EvictsOldEntries()
|
||||
{
|
||||
var opMetrics = new OperationMetrics();
|
||||
for (int i = 0; i < 1100; i++)
|
||||
opMetrics.Record(TimeSpan.FromMilliseconds(i), true);
|
||||
|
||||
var stats = opMetrics.GetStatistics();
|
||||
stats.TotalCount.ShouldBe(1100);
|
||||
// P95 should be from the last 1000 entries (100-1099)
|
||||
stats.Percentile95Milliseconds.ShouldBeGreaterThan(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginOperation_TimingScopeRecordsOnDispose()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("Test"))
|
||||
{
|
||||
// Simulate some work
|
||||
System.Threading.Thread.Sleep(5);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.ShouldContainKey("Test");
|
||||
stats["Test"].TotalCount.ShouldBe(1);
|
||||
stats["Test"].SuccessCount.ShouldBe(1);
|
||||
stats["Test"].AverageMilliseconds.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginOperation_SetSuccessFalse()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
|
||||
using (var scope = metrics.BeginOperation("Test"))
|
||||
{
|
||||
scope.SetSuccess(false);
|
||||
}
|
||||
|
||||
var stats = metrics.GetStatistics()["Test"];
|
||||
stats.TotalCount.ShouldBe(1);
|
||||
stats.SuccessCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMetrics_UnknownOperation_ReturnsNull()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.GetMetrics("NonExistent").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationNames_AreCaseInsensitive()
|
||||
{
|
||||
using var metrics = new PerformanceMetrics();
|
||||
metrics.RecordOperation("Read", TimeSpan.FromMilliseconds(10));
|
||||
metrics.RecordOperation("read", TimeSpan.FromMilliseconds(20));
|
||||
|
||||
var stats = metrics.GetStatistics();
|
||||
stats.Count.ShouldBe(1);
|
||||
stats["READ"].TotalCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/DataValueConverterTests.cs
Normal file
122
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/DataValueConverterTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class DataValueConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromVtq_Boolean()
|
||||
{
|
||||
var vtq = Vtq.Good(true);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(true);
|
||||
Opc.Ua.StatusCode.IsGood(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_Int32()
|
||||
{
|
||||
var vtq = Vtq.Good(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_Float()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14f);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_Double()
|
||||
{
|
||||
var vtq = Vtq.Good(3.14159);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_String()
|
||||
{
|
||||
var vtq = Vtq.Good("hello");
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_DateTime_IsUtc()
|
||||
{
|
||||
var utcTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
var vtq = new Vtq(utcTime, utcTime, Quality.Good);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
((DateTime)dv.Value).Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_TimeSpan_ConvertedToSeconds()
|
||||
{
|
||||
var vtq = Vtq.Good(TimeSpan.FromMinutes(2.5));
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(150.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_StringArray()
|
||||
{
|
||||
var arr = new[] { "a", "b", "c" };
|
||||
var vtq = Vtq.Good(arr);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_IntArray()
|
||||
{
|
||||
var arr = new[] { 1, 2, 3 };
|
||||
var vtq = Vtq.Good(arr);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBe(arr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_BadQuality_MapsToStatusCode()
|
||||
{
|
||||
var vtq = Vtq.Bad(Quality.BadCommFailure);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
Opc.Ua.StatusCode.IsBad(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_UncertainQuality()
|
||||
{
|
||||
var vtq = Vtq.Uncertain(42);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
Opc.Ua.StatusCode.IsUncertain(dv.StatusCode).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromVtq_NullValue()
|
||||
{
|
||||
var vtq = Vtq.Good(null);
|
||||
var dv = DataValueConverter.FromVtq(vtq);
|
||||
dv.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToVtq_RoundTrip()
|
||||
{
|
||||
var original = new Vtq(42, DateTime.UtcNow, Quality.Good);
|
||||
var dv = DataValueConverter.FromVtq(original);
|
||||
var roundTrip = DataValueConverter.ToVtq(dv);
|
||||
|
||||
roundTrip.Value.ShouldBe(42);
|
||||
roundTrip.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
Normal file
109
tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class LmxNodeManagerBuildTests
|
||||
{
|
||||
private static (List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes) CreateTestData()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
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 },
|
||||
};
|
||||
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 3, TagName = "TestMachine_001", AttributeName = "MachineID", FullTagReference = "TestMachine_001.MachineID", 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 = 3, TagName = "TestMachine_001", AttributeName = "BatchItems", FullTagReference = "TestMachine_001.BatchItems[]", MxDataType = 5, IsArray = true, ArrayDimension = 50 },
|
||||
};
|
||||
|
||||
return (hierarchy, attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_CreatesCorrectNodeCounts()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.ObjectCount.ShouldBe(2); // TestMachine_001, DelmiaReceiver
|
||||
model.VariableCount.ShouldBe(4); // MachineID, DownloadPath, JobStepNumber, BatchItems
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_TagReferencesPopulated()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true);
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems[]").ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_ArrayVariable_HasCorrectInfo()
|
||||
{
|
||||
var (hierarchy, attributes) = CreateTestData();
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems[]").ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_Areas_AreNotCountedAsObjects()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Area1", BrowseName = "Area1", ParentGobjectId = 0, IsArea = true },
|
||||
new GalaxyObjectInfo { GobjectId = 2, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 1, IsArea = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, new List<GalaxyAttributeInfo>());
|
||||
model.ObjectCount.ShouldBe(1); // Only Obj1, not Area1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_RootNodes_AreTopLevel()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Root1", BrowseName = "Root1", ParentGobjectId = 0, IsArea = true },
|
||||
new GalaxyObjectInfo { GobjectId = 2, TagName = "Child1", BrowseName = "Child1", ParentGobjectId = 1, IsArea = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, new List<GalaxyAttributeInfo>());
|
||||
model.RootNodes.Count.ShouldBe(1); // Only Root1 is a root
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddressSpace_DataTypeMappings()
|
||||
{
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Obj", BrowseName = "Obj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj", AttributeName = "BoolAttr", FullTagReference = "Obj.BoolAttr", MxDataType = 1, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj", AttributeName = "IntAttr", FullTagReference = "Obj.IntAttr", MxDataType = 2, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj", AttributeName = "FloatAttr", FullTagReference = "Obj.FloatAttr", MxDataType = 3, IsArray = false },
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj", AttributeName = "StrAttr", FullTagReference = "Obj.StrAttr", MxDataType = 5, IsArray = false },
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
model.VariableCount.ShouldBe(4);
|
||||
model.NodeIdToTagReference.Count.ShouldBe(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class LmxNodeManagerRebuildTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rebuild_NewBuild_ReplacesOldData()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "OldObj", BrowseName = "OldObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs1 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "OldObj", AttributeName = "OldAttr", FullTagReference = "OldObj.OldAttr", MxDataType = 5, IsArray = false }
|
||||
};
|
||||
|
||||
var model1 = AddressSpaceBuilder.Build(hierarchy1, attrs1);
|
||||
model1.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(true);
|
||||
|
||||
// Rebuild with new data
|
||||
var hierarchy2 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 2, TagName = "NewObj", BrowseName = "NewObj", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attrs2 = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 2, TagName = "NewObj", AttributeName = "NewAttr", FullTagReference = "NewObj.NewAttr", MxDataType = 2, IsArray = false }
|
||||
};
|
||||
|
||||
var model2 = AddressSpaceBuilder.Build(hierarchy2, attrs2);
|
||||
|
||||
// Old nodes not in new model, new nodes present
|
||||
model2.NodeIdToTagReference.ContainsKey("OldObj.OldAttr").ShouldBe(false);
|
||||
model2.NodeIdToTagReference.ContainsKey("NewObj.NewAttr").ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rebuild_UpdatesNodeCounts()
|
||||
{
|
||||
var hierarchy1 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false },
|
||||
new GalaxyObjectInfo { GobjectId = 2, TagName = "Obj2", BrowseName = "Obj2", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var model1 = AddressSpaceBuilder.Build(hierarchy1, new List<GalaxyAttributeInfo>());
|
||||
model1.ObjectCount.ShouldBe(2);
|
||||
|
||||
var hierarchy2 = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 3, TagName = "Obj3", BrowseName = "Obj3", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var model2 = AddressSpaceBuilder.Build(hierarchy2, new List<GalaxyAttributeInfo>());
|
||||
model2.ObjectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyHierarchy_ProducesEmptyModel()
|
||||
{
|
||||
var model = AddressSpaceBuilder.Build(new List<GalaxyObjectInfo>(), new List<GalaxyAttributeInfo>());
|
||||
model.RootNodes.ShouldBeEmpty();
|
||||
model.NodeIdToTagReference.ShouldBeEmpty();
|
||||
model.ObjectCount.ShouldBe(0);
|
||||
model.VariableCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
|
||||
{
|
||||
public class OpcUaQualityMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Good_MapsToGoodStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Good);
|
||||
StatusCode.IsGood(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bad_MapsToBadStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Bad);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uncertain_MapsToUncertainStatusCode()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.Uncertain);
|
||||
StatusCode.IsUncertain(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadCommFailure_MapsCorrectly()
|
||||
{
|
||||
var sc = OpcUaQualityMapper.ToStatusCode(Quality.BadCommFailure);
|
||||
StatusCode.IsBad(sc).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_Good()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Good);
|
||||
q.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_Bad()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Bad);
|
||||
q.ShouldBe(Quality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromStatusCode_Uncertain()
|
||||
{
|
||||
var q = OpcUaQualityMapper.FromStatusCode(StatusCodes.Uncertain);
|
||||
q.ShouldBe(Quality.Uncertain);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
tests/ZB.MOM.WW.LmxOpcUa.Tests/SampleTest.cs
Normal file
14
tests/ZB.MOM.WW.LmxOpcUa.Tests/SampleTest.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests
|
||||
{
|
||||
public class SampleTest
|
||||
{
|
||||
[Fact]
|
||||
public void Placeholder_ShouldPass()
|
||||
{
|
||||
true.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.GalaxyRepository;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: Galaxy change detection → OnGalaxyChanged → address space rebuild
|
||||
/// </summary>
|
||||
public class ChangeDetectionToRebuildWiringTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task ChangedTimestamp_TriggersRebuild()
|
||||
{
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
LastDeployTime = new DateTime(2024, 1, 1),
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "Obj1", BrowseName = "Obj1", ParentGobjectId = 0, IsArea = false }
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "Obj1", AttributeName = "Attr1", FullTagReference = "Obj1.Attr1", MxDataType = 5, IsArray = false }
|
||||
}
|
||||
};
|
||||
|
||||
var rebuildCount = 0;
|
||||
var service = new ChangeDetectionService(repo, intervalSeconds: 1);
|
||||
service.OnGalaxyChanged += () => Interlocked.Increment(ref rebuildCount);
|
||||
|
||||
service.Start();
|
||||
await Task.Delay(500); // First poll triggers
|
||||
rebuildCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Change deploy time → should trigger rebuild
|
||||
repo.LastDeployTime = new DateTime(2024, 2, 1);
|
||||
await Task.Delay(1500);
|
||||
service.Stop();
|
||||
|
||||
rebuildCount.ShouldBeGreaterThanOrEqualTo(2);
|
||||
service.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: FakeMxProxy OnDataChange → MxAccessClient → OnTagValueChanged → node manager delivery
|
||||
/// </summary>
|
||||
public class MxAccessToNodeManagerWiringTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task DataChange_ReachesGlobalHandler()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
string? receivedAddress = null;
|
||||
Vtq? receivedVtq = null;
|
||||
|
||||
mxClient.OnTagValueChanged += (addr, vtq) =>
|
||||
{
|
||||
receivedAddress = addr;
|
||||
receivedVtq = vtq;
|
||||
};
|
||||
|
||||
mxClient.SimulateDataChange("TestTag.Attr", Vtq.Good(42));
|
||||
|
||||
receivedAddress.ShouldBe("TestTag.Attr");
|
||||
receivedVtq.ShouldNotBeNull();
|
||||
receivedVtq.Value.Value.ShouldBe(42);
|
||||
receivedVtq.Value.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataChange_ReachesSubscriptionCallback()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
Vtq? received = null;
|
||||
|
||||
await mxClient.SubscribeAsync("TestTag.Attr", (addr, vtq) => received = vtq);
|
||||
mxClient.SimulateDataChange("TestTag.Attr", Vtq.Good(99));
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received.Value.Value.ShouldBe(99);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: OPC UA Read → NodeManager → IMxAccessClient.ReadAsync with correct full_tag_reference
|
||||
/// </summary>
|
||||
public class OpcUaReadToMxAccessWiringTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task Read_ResolvesCorrectTagReference()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
mxClient.TagValues["DelmiaReceiver_001.DownloadPath"] = Vtq.Good("/some/path");
|
||||
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 0, IsArea = false },
|
||||
new GalaxyObjectInfo { GobjectId = 2, TagName = "DelmiaReceiver_001", ContainedName = "DelmiaReceiver", BrowseName = "DelmiaReceiver", ParentGobjectId = 1, IsArea = false }
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 2, TagName = "DelmiaReceiver_001", AttributeName = "DownloadPath", FullTagReference = "DelmiaReceiver_001.DownloadPath", MxDataType = 5, IsArray = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
|
||||
// The model should contain the correct tag reference
|
||||
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
|
||||
model.NodeIdToTagReference["DelmiaReceiver_001.DownloadPath"].ShouldBe("DelmiaReceiver_001.DownloadPath");
|
||||
|
||||
// The MxAccessClient should be able to read using the tag reference
|
||||
var vtq = await mxClient.ReadAsync("DelmiaReceiver_001.DownloadPath");
|
||||
vtq.Value.ShouldBe("/some/path");
|
||||
vtq.Quality.ShouldBe(Quality.Good);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: OPC UA Write → NodeManager → IMxAccessClient.WriteAsync with correct tag+value
|
||||
/// </summary>
|
||||
public class OpcUaWriteToMxAccessWiringTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task Write_SendsCorrectTagAndValue()
|
||||
{
|
||||
var mxClient = new FakeMxAccessClient();
|
||||
|
||||
var hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "TestMachine_001", BrowseName = "TestMachine_001", ParentGobjectId = 0, IsArea = false }
|
||||
};
|
||||
var attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestMachine_001", AttributeName = "MachineCode", FullTagReference = "TestMachine_001.MachineCode", MxDataType = 5, IsArray = false }
|
||||
};
|
||||
|
||||
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
|
||||
var tagRef = model.NodeIdToTagReference["TestMachine_001.MachineCode"];
|
||||
|
||||
// Write through MxAccessClient
|
||||
var result = await mxClient.WriteAsync(tagRef, "NEW_CODE");
|
||||
|
||||
result.ShouldBe(true);
|
||||
mxClient.WrittenValues.ShouldContain(w => w.Tag == "TestMachine_001.MachineCode" && (string)w.Value == "NEW_CODE");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Generic;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: OpcUaService Start() creates and wires all components with fakes.
|
||||
/// </summary>
|
||||
public class ServiceStartupSequenceTest
|
||||
{
|
||||
[Fact]
|
||||
public void Start_WithFakes_AllComponentsCreated()
|
||||
{
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration
|
||||
{
|
||||
Port = 14840,
|
||||
GalaxyName = "TestGalaxy",
|
||||
EndpointPath = "/LmxOpcUa"
|
||||
},
|
||||
MxAccess = new MxAccessConfiguration { ClientName = "Test" },
|
||||
GalaxyRepository = new GalaxyRepositoryConfiguration(),
|
||||
Dashboard = new DashboardConfiguration { Enabled = false } // Don't start HTTP listener in tests
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy();
|
||||
var repo = new FakeGalaxyRepository
|
||||
{
|
||||
Hierarchy = new List<GalaxyObjectInfo>
|
||||
{
|
||||
new GalaxyObjectInfo { GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false }
|
||||
},
|
||||
Attributes = new List<GalaxyAttributeInfo>
|
||||
{
|
||||
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "TestAttr", FullTagReference = "TestObj.TestAttr", MxDataType = 5, IsArray = false }
|
||||
}
|
||||
};
|
||||
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
service.Start();
|
||||
|
||||
try
|
||||
{
|
||||
// Verify all components were created
|
||||
service.MxClient.ShouldNotBeNull();
|
||||
service.MxClient!.State.ShouldBe(ConnectionState.Connected);
|
||||
service.Metrics.ShouldNotBeNull();
|
||||
service.ServerHost.ShouldNotBeNull();
|
||||
service.ChangeDetectionInstance.ShouldNotBeNull();
|
||||
service.GalaxyStatsInstance.ShouldNotBeNull();
|
||||
service.GalaxyStatsInstance!.GalaxyName.ShouldBe("TestGalaxy");
|
||||
service.GalaxyStatsInstance.DbConnected.ShouldBe(true);
|
||||
service.StatusReportInstance.ShouldNotBeNull();
|
||||
|
||||
// Dashboard disabled → no web server
|
||||
service.StatusWeb.ShouldBeNull();
|
||||
|
||||
// MxProxy should have been registered
|
||||
proxy.IsRegistered.ShouldBe(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
service.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Wiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies: Start then Stop completes within 30 seconds. (SVC-004)
|
||||
/// </summary>
|
||||
public class ShutdownCompletesTest
|
||||
{
|
||||
[Fact]
|
||||
public void Shutdown_CompletesWithin30Seconds()
|
||||
{
|
||||
var config = new AppConfiguration
|
||||
{
|
||||
OpcUa = new OpcUaConfiguration { Port = 14841, GalaxyName = "TestGalaxy" },
|
||||
MxAccess = new MxAccessConfiguration { ClientName = "Test" },
|
||||
Dashboard = new DashboardConfiguration { Enabled = false }
|
||||
};
|
||||
|
||||
var proxy = new FakeMxProxy();
|
||||
var repo = new FakeGalaxyRepository();
|
||||
var service = new OpcUaService(config, proxy, repo);
|
||||
|
||||
service.Start();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
service.Stop();
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.TotalSeconds.ShouldBeLessThan(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.LmxOpcUa.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxOpcUa.Host\ZB.MOM.WW.LmxOpcUa.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\src\ZB.MOM.WW.LmxOpcUa.Host\appsettings.json" Link="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user