feat(commons): AttributeValueCodec for canonical list value encode/decode
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical, round-trippable codec for attribute values. Scalars encode to an
|
||||||
|
/// invariant-culture string (identical to the historical representation); List
|
||||||
|
/// attributes encode to a JSON array. Used wherever a value is stored or
|
||||||
|
/// transmitted (DB Value column, site SQLite, gRPC wire). <see cref="ValueFormatter"/>
|
||||||
|
/// remains a separate, display-only (comma-joined) formatter.
|
||||||
|
/// </summary>
|
||||||
|
public static class AttributeValueCodec
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
|
||||||
|
|
||||||
|
/// <summary>Encodes a value to its canonical string form.</summary>
|
||||||
|
public static string? Encode(object? value)
|
||||||
|
{
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case null: return null;
|
||||||
|
case string s: return s; // already canonical
|
||||||
|
case IFormattable f: return f.ToString(null, CultureInfo.InvariantCulture);
|
||||||
|
case IEnumerable e:
|
||||||
|
var items = e.Cast<object?>()
|
||||||
|
.Select(x => x is IFormattable xf
|
||||||
|
? xf.ToString(null, CultureInfo.InvariantCulture)
|
||||||
|
: x?.ToString());
|
||||||
|
return JsonSerializer.Serialize(items, JsonOpts);
|
||||||
|
default: return value.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes a canonical string. For <see cref="DataType.List"/> returns a typed
|
||||||
|
/// <c>List<T></c>; for scalars returns the string unchanged. Throws
|
||||||
|
/// <see cref="FormatException"/> on malformed list JSON or an un-parseable element.
|
||||||
|
/// </summary>
|
||||||
|
public static object? Decode(string? value, DataType dataType, DataType? elementType)
|
||||||
|
{
|
||||||
|
if (dataType != DataType.List) return value; // scalar: unchanged
|
||||||
|
if (string.IsNullOrEmpty(value)) return null;
|
||||||
|
if (elementType is null)
|
||||||
|
throw new FormatException("List attribute requires an element type.");
|
||||||
|
|
||||||
|
string?[] raw;
|
||||||
|
try { raw = JsonSerializer.Deserialize<string?[]>(value) ?? []; }
|
||||||
|
catch (JsonException ex) { throw new FormatException("Malformed list JSON.", ex); }
|
||||||
|
|
||||||
|
var clrType = ElementClrType(elementType.Value);
|
||||||
|
var listType = typeof(List<>).MakeGenericType(clrType);
|
||||||
|
var result = (IList)Activator.CreateInstance(listType)!;
|
||||||
|
foreach (var item in raw)
|
||||||
|
result.Add(ParseScalar(item, elementType.Value));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type ElementClrType(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? ParseScalar(string? s, DataType t)
|
||||||
|
{
|
||||||
|
if (s is null) throw new FormatException("List elements may not be null.");
|
||||||
|
var c = CultureInfo.InvariantCulture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
DataType.String => s,
|
||||||
|
DataType.Int32 => int.Parse(s, c),
|
||||||
|
DataType.Float => float.Parse(s, c),
|
||||||
|
DataType.Double => double.Parse(s, c),
|
||||||
|
DataType.Boolean => bool.Parse(s),
|
||||||
|
DataType.DateTime => DateTime.Parse(s, c, DateTimeStyles.RoundtripKind),
|
||||||
|
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or OverflowException)
|
||||||
|
{
|
||||||
|
throw new FormatException($"List element '{s}' is not a valid {t}.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the type may be a List element scalar.</summary>
|
||||||
|
public static bool IsValidElementType(DataType t) =>
|
||||||
|
t is DataType.String or DataType.Int32 or DataType.Float
|
||||||
|
or DataType.Double or DataType.Boolean or DataType.DateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="AttributeValueCodec"/> — the canonical, round-trippable
|
||||||
|
/// codec used wherever an attribute value is stored or transmitted. Scalars must
|
||||||
|
/// encode to their historical invariant-culture string; List attributes encode to
|
||||||
|
/// a JSON array and decode back to a typed <c>List<T></c>.
|
||||||
|
/// </summary>
|
||||||
|
public class AttributeValueCodecTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Encode_StringList_ProducesJsonArray() =>
|
||||||
|
Assert.Equal("[\"WO-1\",\"WO-2\"]",
|
||||||
|
AttributeValueCodec.Encode(new List<string> { "WO-1", "WO-2" }));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_Scalar_String_ReturnedAsIs() =>
|
||||||
|
Assert.Equal("hello", AttributeValueCodec.Encode("hello"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_Scalar_Double_IsInvariant()
|
||||||
|
{
|
||||||
|
var original = CultureInfo.CurrentCulture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||||
|
Assert.Equal("1.5", AttributeValueCodec.Encode(1.5));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_Null_ReturnsNull() =>
|
||||||
|
Assert.Null(AttributeValueCodec.Encode(null));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_EmptyList_IsBracketPair() =>
|
||||||
|
Assert.Equal("[]", AttributeValueCodec.Encode(new List<string>()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_StringWithComma_IsEscaped() =>
|
||||||
|
Assert.Equal("[\"ACME, Inc.\"]",
|
||||||
|
AttributeValueCodec.Encode(new List<string> { "ACME, Inc." }));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_DoubleList_IsInvariant()
|
||||||
|
{
|
||||||
|
var original = CultureInfo.CurrentCulture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||||
|
Assert.Equal("[\"1.5\",\"2.5\"]",
|
||||||
|
AttributeValueCodec.Encode(new List<double> { 1.5, 2.5 }));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_Int32List()
|
||||||
|
{
|
||||||
|
var json = AttributeValueCodec.Encode(new List<int> { 1, 2, 3 });
|
||||||
|
var back = (IList<int>)AttributeValueCodec.Decode(json, DataType.List, DataType.Int32)!;
|
||||||
|
Assert.Equal(new[] { 1, 2, 3 }, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_DoubleList_IsCultureInvariant()
|
||||||
|
{
|
||||||
|
var original = CultureInfo.CurrentCulture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||||
|
var json = AttributeValueCodec.Encode(new List<double> { 1.5, 2.5 });
|
||||||
|
var back = (IList<double>)AttributeValueCodec.Decode(json, DataType.List, DataType.Double)!;
|
||||||
|
Assert.Equal(new[] { 1.5, 2.5 }, back);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_BoolList()
|
||||||
|
{
|
||||||
|
var json = AttributeValueCodec.Encode(new List<bool> { true, false, true });
|
||||||
|
var back = (IList<bool>)AttributeValueCodec.Decode(json, DataType.List, DataType.Boolean)!;
|
||||||
|
Assert.Equal(new[] { true, false, true }, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_DateTimeList_Iso8601()
|
||||||
|
{
|
||||||
|
var a = new DateTime(2026, 6, 15, 13, 45, 30, DateTimeKind.Utc);
|
||||||
|
var b = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var json = AttributeValueCodec.Encode(new List<DateTime> { a, b });
|
||||||
|
var back = (IList<DateTime>)AttributeValueCodec.Decode(json, DataType.List, DataType.DateTime)!;
|
||||||
|
Assert.Equal(new[] { a, b }, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_StringArray_ProducesListOfString()
|
||||||
|
{
|
||||||
|
var back = (IList<string>)AttributeValueCodec.Decode("[\"a\",\"b\"]", DataType.List, DataType.String)!;
|
||||||
|
Assert.Equal(new[] { "a", "b" }, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_Scalar_ReturnsString() =>
|
||||||
|
Assert.Equal("42", AttributeValueCodec.Decode("42", DataType.Int32, null));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_List_NullValue_ReturnsNull() =>
|
||||||
|
Assert.Null(AttributeValueCodec.Decode(null, DataType.List, DataType.String));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_List_EmptyValue_ReturnsNull() =>
|
||||||
|
Assert.Null(AttributeValueCodec.Decode("", DataType.List, DataType.String));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_MalformedJson_Throws() =>
|
||||||
|
Assert.Throws<FormatException>(() =>
|
||||||
|
AttributeValueCodec.Decode("not json", DataType.List, DataType.String));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_UnparseableElement_Throws() =>
|
||||||
|
Assert.Throws<FormatException>(() =>
|
||||||
|
AttributeValueCodec.Decode("[\"abc\"]", DataType.List, DataType.Int32));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_NullElement_Throws() =>
|
||||||
|
Assert.Throws<FormatException>(() =>
|
||||||
|
AttributeValueCodec.Decode("[null]", DataType.List, DataType.String));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decode_List_WithoutElementType_Throws() =>
|
||||||
|
Assert.Throws<FormatException>(() =>
|
||||||
|
AttributeValueCodec.Decode("[\"a\"]", DataType.List, null));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(DataType.String, true)]
|
||||||
|
[InlineData(DataType.Int32, true)]
|
||||||
|
[InlineData(DataType.Float, true)]
|
||||||
|
[InlineData(DataType.Double, true)]
|
||||||
|
[InlineData(DataType.Boolean, true)]
|
||||||
|
[InlineData(DataType.DateTime, true)]
|
||||||
|
[InlineData(DataType.Binary, false)]
|
||||||
|
[InlineData(DataType.List, false)]
|
||||||
|
public void IsValidElementType_MatchesScalarSet(DataType t, bool expected) =>
|
||||||
|
Assert.Equal(expected, AttributeValueCodec.IsValidElementType(t));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user