diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs index 5fd6b3a3..d5f18681 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs @@ -1,6 +1,8 @@ using System.CommandLine; using System.CommandLine.Parsing; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; @@ -239,9 +241,16 @@ public static class TemplateCommands internal const string ElementTypeOptionDescription = "Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List."; - /// The element scalar types permitted for a List attribute (matches the Management API). + /// + /// The element scalar types permitted for a List attribute — derived from the + /// single source of truth, , + /// so the CLI never drifts from the codec/Management API. + /// private static readonly string[] ValidElementScalars = - { "String", "Int32", "Float", "Double", "Boolean", "DateTime" }; + Enum.GetValues() + .Where(AttributeValueCodec.IsValidElementType) + .Select(t => t.ToString()) + .ToArray(); /// /// Validates the --data-type / --element-type combination client-side so @@ -268,7 +277,8 @@ public static class TemplateCommands return false; } - if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase)) + if (!Enum.TryParse(elementType!.Trim(), ignoreCase: true, out var parsed) + || !AttributeValueCodec.IsValidElementType(parsed)) { error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: " + string.Join(", ", ValidElementScalars) + "."; diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs index b848b79e..36e34411 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs @@ -64,7 +64,13 @@ public static class AttributeValueCodec _ => el.GetRawText() // number/bool → "10" / "1.5" / "true" }; - private static Type ElementClrType(DataType t) => t switch + /// + /// The CLR element type backing a of the given + /// element scalar — the single source of truth for the List element CLR + /// mapping. Throws for an unsupported element + /// type (see ). + /// + public static Type ElementClrType(DataType t) => t switch { DataType.String => typeof(string), DataType.Int32 => typeof(int), @@ -75,6 +81,49 @@ public static class AttributeValueCodec _ => throw new FormatException($"Unsupported list element type '{t}'.") }; + /// + /// Coerces a live CLR enumerable (e.g. an OPC UA array) into a typed + /// List<> for the given element type, + /// converting each element with invariant culture (round-trip parse for + /// DateTime). Strings are parsed; other CLR types are converted via + /// . This is the object-input counterpart to the + /// string-input parsing in : callers holding decoded JSON + /// strings go through ; callers holding a runtime + /// collection use this. Throws for an + /// unsupported element type and may throw on an element that cannot be + /// converted (the caller decides how to handle the failure). + /// + public static IList CoerceEnumerable(IEnumerable source, DataType elementType) + { + ArgumentNullException.ThrowIfNull(source); + var clrType = ElementClrType(elementType); + var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(clrType))!; + foreach (var element in source) + list.Add(CoerceElement(element, elementType)); + return list; + } + + private static object CoerceElement(object? element, DataType t) + { + if (element is null) + throw new FormatException("List elements may not be null."); + + var c = CultureInfo.InvariantCulture; + return t switch + { + DataType.String => Convert.ToString(element, c) + ?? throw new FormatException("Null string element."), + DataType.Int32 => element is string si ? int.Parse(si, c) : Convert.ToInt32(element, c), + DataType.Float => element is string sf ? float.Parse(sf, c) : Convert.ToSingle(element, c), + DataType.Double => element is string sd ? double.Parse(sd, c) : Convert.ToDouble(element, c), + DataType.Boolean => element is string sb ? bool.Parse(sb) : Convert.ToBoolean(element, c), + DataType.DateTime => element is string sdt + ? DateTime.Parse(sdt, c, DateTimeStyles.RoundtripKind) + : Convert.ToDateTime(element, c), + _ => throw new FormatException($"Unsupported list element type '{t}'.") + }; + } + private static object? ParseScalar(string? s, DataType t) { if (s is null) throw new FormatException("List elements may not be null."); diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index 327cf6e9..db05db94 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -896,20 +896,12 @@ public class InstanceActor : ReceiveActor try { - // Construct the typed list INSIDE the try: although the six valid - // element types resolved by ListElementClrType cannot throw today, - // keeping ListElementClrType / MakeGenericType / CreateInstance inside - // the guarded block means any future change that introduces a throw - // here is caught and turned into a Bad-quality result rather than + // Coerce INSIDE the try: although the six valid element types cannot + // throw on construction today, keeping the (shared) codec coercion + // inside the guarded block means any future change that introduces a + // throw is caught and turned into a Bad-quality result rather than // escaping into the actor and tripping supervision. - var clrType = ListElementClrType(elementType); - var list = (System.Collections.IList)Activator.CreateInstance( - typeof(List<>).MakeGenericType(clrType))!; - - foreach (var element in enumerable) - list.Add(CoerceElement(element, elementType)); - - typedList = list; + typedList = AttributeValueCodec.CoerceEnumerable(enumerable, elementType); return true; } catch (Exception ex) @@ -922,46 +914,6 @@ public class InstanceActor : ReceiveActor } } - private static Type ListElementClrType(DataType t) => t switch - { - DataType.String => typeof(string), - DataType.Int32 => typeof(int), - DataType.Float => typeof(float), - DataType.Double => typeof(double), - DataType.Boolean => typeof(bool), - DataType.DateTime => typeof(DateTime), - _ => throw new FormatException($"Unsupported list element type '{t}'.") - }; - - private static object CoerceElement(object? element, DataType t) - { - if (element is null) - throw new FormatException("List elements may not be null."); - - var culture = System.Globalization.CultureInfo.InvariantCulture; - return t switch - { - DataType.String => Convert.ToString(element, culture) - ?? throw new FormatException("Null string element."), - DataType.Int32 => element is string si - ? int.Parse(si, culture) - : Convert.ToInt32(element, culture), - DataType.Float => element is string sf - ? float.Parse(sf, culture) - : Convert.ToSingle(element, culture), - DataType.Double => element is string sd - ? double.Parse(sd, culture) - : Convert.ToDouble(element, culture), - DataType.Boolean => element is string sb - ? bool.Parse(sb) - : Convert.ToBoolean(element, culture), - DataType.DateTime => element is string sdt - ? DateTime.Parse(sdt, culture, System.Globalization.DateTimeStyles.RoundtripKind) - : Convert.ToDateTime(element, culture), - _ => throw new FormatException($"Unsupported list element type '{t}'.") - }; - } - private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged) { _logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}", diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs index fa717bdb..3f81ba37 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs @@ -231,4 +231,56 @@ public class AttributeValueCodecTests [InlineData(DataType.List, false)] public void IsValidElementType_MatchesScalarSet(DataType t, bool expected) => Assert.Equal(expected, AttributeValueCodec.IsValidElementType(t)); + + // ── ElementClrType + CoerceEnumerable: the shared object-input coercion ── + // entry point reused by InstanceActor (live OPC UA arrays). Distinct from the + // string-input path in Decode (JSON elements): here elements arrive as boxed + // CLR values and are converted via Convert.ToXxx (or parsed when string). + + [Theory] + [InlineData(DataType.String, typeof(string))] + [InlineData(DataType.Int32, typeof(int))] + [InlineData(DataType.Float, typeof(float))] + [InlineData(DataType.Double, typeof(double))] + [InlineData(DataType.Boolean, typeof(bool))] + [InlineData(DataType.DateTime, typeof(DateTime))] + public void ElementClrType_MapsEachScalar(DataType t, Type expected) => + Assert.Equal(expected, AttributeValueCodec.ElementClrType(t)); + + [Fact] + public void ElementClrType_RejectsNonElementType() => + Assert.Throws(() => AttributeValueCodec.ElementClrType(DataType.List)); + + [Fact] + public void CoerceEnumerable_BuildsTypedList() + { + var list = AttributeValueCodec.CoerceEnumerable(new object[] { 10, 20, 30 }, DataType.Int32); + var typed = Assert.IsType>(list); + Assert.Equal(new[] { 10, 20, 30 }, typed); + } + + [Fact] + public void CoerceEnumerable_ConvertsMixedClrElementTypes() + { + // Ints and numeric strings both coerce to List via Convert/parse — + // the object-input behavior InstanceActor's MV-8 coercion depends on. + var list = AttributeValueCodec.CoerceEnumerable(new object[] { 1, "2.5", 3.75 }, DataType.Double); + var typed = Assert.IsType>(list); + Assert.Equal(new[] { 1.0, 2.5, 3.75 }, typed); + } + + [Fact] + public void CoerceEnumerable_DateTimeRoundtripsViaInvariantCulture() + { + var when = new DateTime(2026, 6, 19, 8, 30, 0, DateTimeKind.Utc); + var list = AttributeValueCodec.CoerceEnumerable( + new object[] { when.ToString("O", CultureInfo.InvariantCulture) }, DataType.DateTime); + var typed = Assert.IsType>(list); + Assert.Equal(when, typed[0]); + } + + [Fact] + public void CoerceEnumerable_ThrowsOnUnsupportedElementType() => + Assert.Throws( + () => AttributeValueCodec.CoerceEnumerable(new object[] { new byte[] { 1 } }, DataType.Binary)); }