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;
///
/// Gitea #58: the scalar write-coercion table that
/// applies before wrapping a value in a
/// . 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.
///
[Trait("Category", "Unit")]
public class OpcUaWriteCoercionTests
{
public static TheoryData 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 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 { 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);
}
}