Add cross-platform OPC UA client stack: shared library, CLI tool, and Avalonia UI
Implements Client.Shared (IOpcUaClientService with connection lifecycle, failover, browse, read/write, subscriptions, alarms, history, redundancy), Client.CLI (8 CliFx commands mirroring tools/opcuacli-dotnet), and Client.UI (Avalonia desktop app with tree browser, read/write, subscriptions, alarms, and history tabs). All three target .NET 10 and are covered by 249 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
|
||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class AggregateTypeMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AggregateType.Average)]
|
||||
[InlineData(AggregateType.Minimum)]
|
||||
[InlineData(AggregateType.Maximum)]
|
||||
[InlineData(AggregateType.Count)]
|
||||
[InlineData(AggregateType.Start)]
|
||||
[InlineData(AggregateType.End)]
|
||||
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
|
||||
{
|
||||
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
|
||||
nodeId.ShouldNotBeNull();
|
||||
nodeId.IsNullNodeId.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNodeId_Average_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Average).ShouldBe(ObjectIds.AggregateFunction_Average);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNodeId_Minimum_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Minimum).ShouldBe(ObjectIds.AggregateFunction_Minimum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNodeId_Maximum_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Maximum).ShouldBe(ObjectIds.AggregateFunction_Maximum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNodeId_Count_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Count).ShouldBe(ObjectIds.AggregateFunction_Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNodeId_Start_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Start).ShouldBe(ObjectIds.AggregateFunction_Start);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNodeId_End_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.End).ShouldBe(ObjectIds.AggregateFunction_End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNodeId_InvalidValue_Throws()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
AggregateTypeMapper.ToNodeId((AggregateType)99));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class FailoverUrlParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_CsvNull_ReturnsPrimaryOnly()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", (string?)null);
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CsvEmpty_ReturnsPrimaryOnly()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "");
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CsvWhitespace_ReturnsPrimaryOnly()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", " ");
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SingleFailover_ReturnsBoth()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "opc.tcp://backup:4840");
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MultipleFailovers_ReturnsAll()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "opc.tcp://backup1:4840,opc.tcp://backup2:4840");
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup1:4840", "opc.tcp://backup2:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TrimsWhitespace()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", " opc.tcp://backup:4840 ");
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DeduplicatesPrimaryInFailoverList()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", "opc.tcp://primary:4840,opc.tcp://backup:4840");
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DeduplicatesCaseInsensitive()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://Primary:4840", "opc.tcp://primary:4840");
|
||||
result.ShouldBe(new[] { "opc.tcp://Primary:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayNull_ReturnsPrimaryOnly()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", (string[]?)null);
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayEmpty_ReturnsPrimaryOnly()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840", Array.Empty<string>());
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayWithUrls_ReturnsAll()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
|
||||
new[] { "opc.tcp://backup1:4840", "opc.tcp://backup2:4840" });
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup1:4840", "opc.tcp://backup2:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayDeduplicates()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
|
||||
new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArrayTrimsWhitespace()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
|
||||
new[] { " opc.tcp://backup:4840 " });
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ArraySkipsNullAndEmpty()
|
||||
{
|
||||
var result = FailoverUrlParser.Parse("opc.tcp://primary:4840",
|
||||
new[] { null!, "", "opc.tcp://backup:4840" });
|
||||
result.ShouldBe(new[] { "opc.tcp://primary:4840", "opc.tcp://backup:4840" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
|
||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class SecurityModeMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SecurityMode.None, MessageSecurityMode.None)]
|
||||
[InlineData(SecurityMode.Sign, MessageSecurityMode.Sign)]
|
||||
[InlineData(SecurityMode.SignAndEncrypt, MessageSecurityMode.SignAndEncrypt)]
|
||||
public void ToMessageSecurityMode_MapsCorrectly(SecurityMode input, MessageSecurityMode expected)
|
||||
{
|
||||
SecurityModeMapper.ToMessageSecurityMode(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToMessageSecurityMode_InvalidValue_Throws()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
SecurityModeMapper.ToMessageSecurityMode((SecurityMode)99));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("none", SecurityMode.None)]
|
||||
[InlineData("None", SecurityMode.None)]
|
||||
[InlineData("NONE", SecurityMode.None)]
|
||||
[InlineData("sign", SecurityMode.Sign)]
|
||||
[InlineData("Sign", SecurityMode.Sign)]
|
||||
[InlineData("encrypt", SecurityMode.SignAndEncrypt)]
|
||||
[InlineData("signandencrypt", SecurityMode.SignAndEncrypt)]
|
||||
[InlineData("SignAndEncrypt", SecurityMode.SignAndEncrypt)]
|
||||
public void FromString_ParsesCorrectly(string input, SecurityMode expected)
|
||||
{
|
||||
SecurityModeMapper.FromString(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromString_WithWhitespace_ParsesCorrectly()
|
||||
{
|
||||
SecurityModeMapper.FromString(" sign ").ShouldBe(SecurityMode.Sign);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromString_UnknownValue_Throws()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => SecurityModeMapper.FromString("invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromString_Null_DefaultsToNone()
|
||||
{
|
||||
SecurityModeMapper.FromString(null!).ShouldBe(SecurityMode.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Client.Shared.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class ValueConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConvertValue_Bool_True()
|
||||
{
|
||||
ValueConverter.ConvertValue("True", true).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Bool_False()
|
||||
{
|
||||
ValueConverter.ConvertValue("False", false).ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Byte()
|
||||
{
|
||||
ValueConverter.ConvertValue("255", (byte)0).ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Short()
|
||||
{
|
||||
ValueConverter.ConvertValue("-100", (short)0).ShouldBe((short)-100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_UShort()
|
||||
{
|
||||
ValueConverter.ConvertValue("65535", (ushort)0).ShouldBe((ushort)65535);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Int()
|
||||
{
|
||||
ValueConverter.ConvertValue("42", 0).ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_UInt()
|
||||
{
|
||||
ValueConverter.ConvertValue("42", 0u).ShouldBe(42u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Long()
|
||||
{
|
||||
ValueConverter.ConvertValue("9999999999", 0L).ShouldBe(9999999999L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_ULong()
|
||||
{
|
||||
ValueConverter.ConvertValue("18446744073709551615", 0UL).ShouldBe(ulong.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Float()
|
||||
{
|
||||
ValueConverter.ConvertValue("3.14", 0f).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Double()
|
||||
{
|
||||
ValueConverter.ConvertValue("3.14159", 0.0).ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_String_WhenCurrentIsString()
|
||||
{
|
||||
ValueConverter.ConvertValue("hello", "").ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_String_WhenCurrentIsNull()
|
||||
{
|
||||
ValueConverter.ConvertValue("hello", null).ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_String_WhenCurrentIsUnknownType()
|
||||
{
|
||||
ValueConverter.ConvertValue("hello", new object()).ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_InvalidBool_Throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() => ValueConverter.ConvertValue("notabool", true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_InvalidInt_Throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() => ValueConverter.ConvertValue("notanint", 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertValue_Overflow_Throws()
|
||||
{
|
||||
Should.Throw<OverflowException>(() => ValueConverter.ConvertValue("256", (byte)0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user