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); } }