a5c3de63e9
Codec-encoded scalars ("True", "42.5") reached RealOpcUaClient.WriteValuesAsync
as strings and were sent as String Variants, so a Boolean/Double node answered
BadTypeMismatch (0x80740000).
RealOpcUaClient now reads each written node's DataType attribute once per
session (batched, cached, cleared on connect/disconnect; derived types and
enumerations resolved via the supertype chain) and converts non-null scalar
values with the new OpcUaWriteCoercion table: Boolean, SByte..UInt64, Float,
Double, String, DateTime, Guid, all culture-invariant. Overflow, fractional
values to integer types and unparseable strings are refused client-side with
BadTypeMismatch and a message naming the value and target type - never a
rounded value. Arrays, null, unmapped types and nodes whose DataType read
fails pass through unchanged. WriteValueAsync now surfaces the outcome's own
status code instead of always BadNodeIdUnknown. MxGateway path untouched.
Tests: OpcUaWriteCoercionTests (coercion table incl. overflow, invalid and
culture-sensitive strings) and OpcUaWriteCoercionLiveTests (RequiresOpcUa:
String writes to Double/Boolean/UInt32 opc-plc nodes read back typed; reverting
the coercion reproduces 0x80740000). Component-DataConnectionLayer.md updated.
198 lines
7.7 KiB
C#
198 lines
7.7 KiB
C#
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using Opc.Ua;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
|
|
|
/// <summary>
|
|
/// Gitea #58: the scalar write-coercion table <see cref="OpcUaWriteCoercion"/> that
|
|
/// <see cref="RealOpcUaClient.WriteValuesAsync"/> applies before wrapping a value in a
|
|
/// <see cref="Variant"/>. Each success case asserts the exact CLR type (the Variant's built-in
|
|
/// type follows from it); each failure case asserts no value is produced.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public class OpcUaWriteCoercionTests
|
|
{
|
|
public static TheoryData<object, BuiltInType, object> Successes => new()
|
|
{
|
|
// Boolean
|
|
{ "True", BuiltInType.Boolean, true },
|
|
{ "false", BuiltInType.Boolean, false },
|
|
{ " TRUE ", BuiltInType.Boolean, true },
|
|
{ "1", BuiltInType.Boolean, true },
|
|
{ "0", BuiltInType.Boolean, false },
|
|
{ 1, BuiltInType.Boolean, true },
|
|
{ true, BuiltInType.Boolean, true },
|
|
// Signed integers
|
|
{ "-128", BuiltInType.SByte, (sbyte)-128 },
|
|
{ "32767", BuiltInType.Int16, (short)32767 },
|
|
{ "-42", BuiltInType.Int32, -42 },
|
|
{ "42.0", BuiltInType.Int32, 42 },
|
|
{ 7L, BuiltInType.Int32, 7 },
|
|
{ 3.0, BuiltInType.Int32, 3 },
|
|
{ "-9223372036854775808", BuiltInType.Int64, long.MinValue },
|
|
{ "7", BuiltInType.Enumeration, 7 },
|
|
// Unsigned integers
|
|
{ "255", BuiltInType.Byte, (byte)255 },
|
|
{ "65535", BuiltInType.UInt16, (ushort)65535 },
|
|
{ "4294967295", BuiltInType.UInt32, uint.MaxValue },
|
|
{ 5, BuiltInType.UInt32, 5u },
|
|
{ "18446744073709551615", BuiltInType.UInt64, ulong.MaxValue },
|
|
// Floating point (invariant culture: '.' is the decimal separator)
|
|
{ "42.5", BuiltInType.Double, 42.5 },
|
|
{ "-1.5e3", BuiltInType.Double, -1500.0 },
|
|
{ 42, BuiltInType.Double, 42.0 },
|
|
{ 1.25f, BuiltInType.Double, 1.25 },
|
|
{ "Infinity", BuiltInType.Double, double.PositiveInfinity },
|
|
{ "42.5", BuiltInType.Float, 42.5f },
|
|
{ 2.5, BuiltInType.Float, 2.5f },
|
|
// String
|
|
{ "hello", BuiltInType.String, "hello" },
|
|
{ 42.5, BuiltInType.String, "42.5" },
|
|
{ true, BuiltInType.String, "True" },
|
|
// Unmapped target types pass through untouched
|
|
{ "abc", BuiltInType.Variant, "abc" },
|
|
{ "abc", BuiltInType.ExtensionObject, "abc" },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Successes))]
|
|
public void Coerces_to_the_node_type(object input, BuiltInType target, object expected)
|
|
{
|
|
var result = OpcUaWriteCoercion.Coerce(input, target);
|
|
|
|
Assert.True(result.Success, result.Error);
|
|
Assert.Equal(expected.GetType(), result.Value!.GetType());
|
|
Assert.Equal(expected, result.Value);
|
|
}
|
|
|
|
public static TheoryData<object, BuiltInType> Failures => new()
|
|
{
|
|
// Invalid strings
|
|
{ "yes", BuiltInType.Boolean },
|
|
{ "2", BuiltInType.Boolean },
|
|
{ 2, BuiltInType.Boolean },
|
|
{ "abc", BuiltInType.Int32 },
|
|
{ "", BuiltInType.Int32 },
|
|
{ "abc", BuiltInType.Double },
|
|
{ "not-a-date", BuiltInType.DateTime },
|
|
{ "zzz", BuiltInType.Guid },
|
|
// Culture: a comma is not a decimal separator, and thousands separators are refused
|
|
{ "42,5", BuiltInType.Double },
|
|
{ "1,000", BuiltInType.Int32 },
|
|
// Fractional value to an integer type — never rounded
|
|
{ "42.5", BuiltInType.Int32 },
|
|
{ 42.5, BuiltInType.Int64 },
|
|
{ double.NaN, BuiltInType.Int32 },
|
|
// Overflow
|
|
{ "128", BuiltInType.SByte },
|
|
{ "256", BuiltInType.Byte },
|
|
{ "-1", BuiltInType.Byte },
|
|
{ "32768", BuiltInType.Int16 },
|
|
{ "-1", BuiltInType.UInt16 },
|
|
{ "2147483648", BuiltInType.Int32 },
|
|
{ 4294967296L, BuiltInType.UInt32 },
|
|
{ "-1", BuiltInType.UInt32 },
|
|
{ "9223372036854775808", BuiltInType.Int64 },
|
|
{ "18446744073709551616", BuiltInType.UInt64 },
|
|
{ -1, BuiltInType.UInt64 },
|
|
{ 1e30, BuiltInType.Int64 },
|
|
{ "1e999", BuiltInType.Double },
|
|
{ "1e39", BuiltInType.Float },
|
|
{ 1e39, BuiltInType.Float },
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Failures))]
|
|
public void Rejects_values_that_do_not_fit_the_node_type(object input, BuiltInType target)
|
|
{
|
|
var result = OpcUaWriteCoercion.Coerce(input, target);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Null(result.Value);
|
|
Assert.Contains(target == BuiltInType.Enumeration ? "Int32" : target.ToString(), result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void Error_names_the_value_and_target_type()
|
|
{
|
|
var result = OpcUaWriteCoercion.Coerce("300", BuiltInType.Byte);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("\"300\"", result.Error);
|
|
Assert.Contains("Byte", result.Error);
|
|
Assert.Contains("out of range", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void DateTime_strings_parse_invariantly_as_utc()
|
|
{
|
|
var withOffset = OpcUaWriteCoercion.Coerce("2026-11-06T10:00:00+02:00", BuiltInType.DateTime);
|
|
var noOffset = OpcUaWriteCoercion.Coerce("2026-11-06T08:00:00", BuiltInType.DateTime);
|
|
|
|
var expected = new DateTime(2026, 11, 6, 8, 0, 0, DateTimeKind.Utc);
|
|
Assert.Equal(expected, withOffset.Value);
|
|
Assert.Equal(DateTimeKind.Utc, ((DateTime)withOffset.Value!).Kind);
|
|
Assert.Equal(expected, noOffset.Value);
|
|
Assert.Equal(DateTimeKind.Utc, ((DateTime)noOffset.Value!).Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void DateTime_to_string_uses_round_trip_format()
|
|
{
|
|
var dt = new DateTime(2026, 11, 6, 8, 0, 0, DateTimeKind.Utc);
|
|
|
|
Assert.Equal("2026-11-06T08:00:00.0000000Z", OpcUaWriteCoercion.Coerce(dt, BuiltInType.String).Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Parsing_ignores_the_current_culture()
|
|
{
|
|
var saved = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
// de-DE uses ',' as the decimal separator; the invariant parse must still read "42.5".
|
|
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
|
Assert.Equal(42.5, OpcUaWriteCoercion.Coerce("42.5", BuiltInType.Double).Value);
|
|
Assert.Equal("42.5", OpcUaWriteCoercion.Coerce(42.5, BuiltInType.String).Value);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = saved;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Guid_strings_parse()
|
|
{
|
|
var g = Guid.NewGuid();
|
|
Assert.Equal(g, OpcUaWriteCoercion.Coerce(g.ToString(), BuiltInType.Guid).Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Arrays_and_null_pass_through_untouched()
|
|
{
|
|
var list = new List<int> { 1, 2 };
|
|
var array = new[] { "1", "2" };
|
|
|
|
Assert.Same(list, OpcUaWriteCoercion.Coerce(list, BuiltInType.Double).Value);
|
|
Assert.Same(array, OpcUaWriteCoercion.Coerce(array, BuiltInType.Int32).Value);
|
|
Assert.True(OpcUaWriteCoercion.Coerce(null, BuiltInType.Int32).Success);
|
|
Assert.Null(OpcUaWriteCoercion.Coerce(null, BuiltInType.Int32).Value);
|
|
Assert.False(OpcUaWriteCoercion.IsCoercibleScalar(list));
|
|
Assert.True(OpcUaWriteCoercion.IsCoercibleScalar("abc"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Coerced_string_becomes_a_typed_variant()
|
|
{
|
|
// The #58 failure: new Variant("True") is a String Variant. After coercion it is Boolean.
|
|
var coerced = OpcUaWriteCoercion.Coerce("True", BuiltInType.Boolean).Value;
|
|
|
|
Assert.Equal(BuiltInType.String, new Variant("True").TypeInfo.BuiltInType);
|
|
Assert.Equal(BuiltInType.Boolean, new Variant(coerced).TypeInfo.BuiltInType);
|
|
Assert.Equal(ValueRanks.Scalar, new Variant(coerced).TypeInfo.ValueRank);
|
|
}
|
|
}
|