refactor(commons): consolidate List element-type/coercion into AttributeValueCodec; InstanceActor + CLI reuse it (#93)
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
using System.CommandLine;
|
using System.CommandLine;
|
||||||
using System.CommandLine.Parsing;
|
using System.CommandLine.Parsing;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
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;
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||||
|
|
||||||
@@ -239,9 +241,16 @@ public static class TemplateCommands
|
|||||||
internal const string ElementTypeOptionDescription =
|
internal const string ElementTypeOptionDescription =
|
||||||
"Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List.";
|
"Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List.";
|
||||||
|
|
||||||
/// <summary>The element scalar types permitted for a List attribute (matches the Management API).</summary>
|
/// <summary>
|
||||||
|
/// The element scalar types permitted for a List attribute — derived from the
|
||||||
|
/// single source of truth, <see cref="AttributeValueCodec.IsValidElementType"/>,
|
||||||
|
/// so the CLI never drifts from the codec/Management API.
|
||||||
|
/// </summary>
|
||||||
private static readonly string[] ValidElementScalars =
|
private static readonly string[] ValidElementScalars =
|
||||||
{ "String", "Int32", "Float", "Double", "Boolean", "DateTime" };
|
Enum.GetValues<DataType>()
|
||||||
|
.Where(AttributeValueCodec.IsValidElementType)
|
||||||
|
.Select(t => t.ToString())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the <c>--data-type</c> / <c>--element-type</c> combination client-side so
|
/// Validates the <c>--data-type</c> / <c>--element-type</c> combination client-side so
|
||||||
@@ -268,7 +277,8 @@ public static class TemplateCommands
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase))
|
if (!Enum.TryParse<DataType>(elementType!.Trim(), ignoreCase: true, out var parsed)
|
||||||
|
|| !AttributeValueCodec.IsValidElementType(parsed))
|
||||||
{
|
{
|
||||||
error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: "
|
error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: "
|
||||||
+ string.Join(", ", ValidElementScalars) + ".";
|
+ string.Join(", ", ValidElementScalars) + ".";
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ public static class AttributeValueCodec
|
|||||||
_ => el.GetRawText() // number/bool → "10" / "1.5" / "true"
|
_ => el.GetRawText() // number/bool → "10" / "1.5" / "true"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Type ElementClrType(DataType t) => t switch
|
/// <summary>
|
||||||
|
/// The CLR element type backing a <see cref="DataType.List"/> of the given
|
||||||
|
/// element scalar — the single source of truth for the List element CLR
|
||||||
|
/// mapping. Throws <see cref="FormatException"/> for an unsupported element
|
||||||
|
/// type (see <see cref="IsValidElementType"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static Type ElementClrType(DataType t) => t switch
|
||||||
{
|
{
|
||||||
DataType.String => typeof(string),
|
DataType.String => typeof(string),
|
||||||
DataType.Int32 => typeof(int),
|
DataType.Int32 => typeof(int),
|
||||||
@@ -75,6 +81,49 @@ public static class AttributeValueCodec
|
|||||||
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coerces a live CLR enumerable (e.g. an OPC UA array) into a typed
|
||||||
|
/// <c>List<<see cref="ElementClrType"/>></c> 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
|
||||||
|
/// <see cref="Convert"/>. This is the object-input counterpart to the
|
||||||
|
/// string-input parsing in <see cref="Decode"/>: callers holding decoded JSON
|
||||||
|
/// strings go through <see cref="Decode"/>; callers holding a runtime
|
||||||
|
/// collection use this. Throws <see cref="FormatException"/> for an
|
||||||
|
/// unsupported element type and may throw on an element that cannot be
|
||||||
|
/// converted (the caller decides how to handle the failure).
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static object? ParseScalar(string? s, DataType t)
|
||||||
{
|
{
|
||||||
if (s is null) throw new FormatException("List elements may not be null.");
|
if (s is null) throw new FormatException("List elements may not be null.");
|
||||||
|
|||||||
@@ -896,20 +896,12 @@ public class InstanceActor : ReceiveActor
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Construct the typed list INSIDE the try: although the six valid
|
// Coerce INSIDE the try: although the six valid element types cannot
|
||||||
// element types resolved by ListElementClrType cannot throw today,
|
// throw on construction today, keeping the (shared) codec coercion
|
||||||
// keeping ListElementClrType / MakeGenericType / CreateInstance inside
|
// inside the guarded block means any future change that introduces a
|
||||||
// the guarded block means any future change that introduces a throw
|
// throw is caught and turned into a Bad-quality result rather than
|
||||||
// here is caught and turned into a Bad-quality result rather than
|
|
||||||
// escaping into the actor and tripping supervision.
|
// escaping into the actor and tripping supervision.
|
||||||
var clrType = ListElementClrType(elementType);
|
typedList = AttributeValueCodec.CoerceEnumerable(enumerable, elementType);
|
||||||
var list = (System.Collections.IList)Activator.CreateInstance(
|
|
||||||
typeof(List<>).MakeGenericType(clrType))!;
|
|
||||||
|
|
||||||
foreach (var element in enumerable)
|
|
||||||
list.Add(CoerceElement(element, elementType));
|
|
||||||
|
|
||||||
typedList = list;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
|
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
|
||||||
|
|||||||
@@ -231,4 +231,56 @@ public class AttributeValueCodecTests
|
|||||||
[InlineData(DataType.List, false)]
|
[InlineData(DataType.List, false)]
|
||||||
public void IsValidElementType_MatchesScalarSet(DataType t, bool expected) =>
|
public void IsValidElementType_MatchesScalarSet(DataType t, bool expected) =>
|
||||||
Assert.Equal(expected, AttributeValueCodec.IsValidElementType(t));
|
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<FormatException>(() => 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<int>>(list);
|
||||||
|
Assert.Equal(new[] { 10, 20, 30 }, typed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CoerceEnumerable_ConvertsMixedClrElementTypes()
|
||||||
|
{
|
||||||
|
// Ints and numeric strings both coerce to List<double> 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<double>>(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<DateTime>>(list);
|
||||||
|
Assert.Equal(when, typed[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CoerceEnumerable_ThrowsOnUnsupportedElementType() =>
|
||||||
|
Assert.Throws<FormatException>(
|
||||||
|
() => AttributeValueCodec.CoerceEnumerable(new object[] { new byte[] { 1 } }, DataType.Binary));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user