diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs new file mode 100644 index 00000000..c4abd822 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/AttributeValueCodec.cs @@ -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; + +/// +/// 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). +/// remains a separate, display-only (comma-joined) formatter. +/// +public static class AttributeValueCodec +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false }; + + /// Encodes a value to its canonical string form. + 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() + .Select(x => x is IFormattable xf + ? xf.ToString(null, CultureInfo.InvariantCulture) + : x?.ToString()); + return JsonSerializer.Serialize(items, JsonOpts); + default: return value.ToString(); + } + } + + /// + /// Decodes a canonical string. For returns a typed + /// List<T>; for scalars returns the string unchanged. Throws + /// on malformed list JSON or an un-parseable element. + /// + 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(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); + } + } + + /// True if the type may be a List element scalar. + 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; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs new file mode 100644 index 00000000..ac4dce9e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/AttributeValueCodecTests.cs @@ -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; + +/// +/// Tests for — 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 List<T>. +/// +public class AttributeValueCodecTests +{ + [Fact] + public void Encode_StringList_ProducesJsonArray() => + Assert.Equal("[\"WO-1\",\"WO-2\"]", + AttributeValueCodec.Encode(new List { "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())); + + [Fact] + public void Encode_StringWithComma_IsEscaped() => + Assert.Equal("[\"ACME, Inc.\"]", + AttributeValueCodec.Encode(new List { "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 { 1.5, 2.5 })); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Fact] + public void RoundTrip_Int32List() + { + var json = AttributeValueCodec.Encode(new List { 1, 2, 3 }); + var back = (IList)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 { 1.5, 2.5 }); + var back = (IList)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 { true, false, true }); + var back = (IList)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 { a, b }); + var back = (IList)AttributeValueCodec.Decode(json, DataType.List, DataType.DateTime)!; + Assert.Equal(new[] { a, b }, back); + } + + [Fact] + public void Decode_StringArray_ProducesListOfString() + { + var back = (IList)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(() => + AttributeValueCodec.Decode("not json", DataType.List, DataType.String)); + + [Fact] + public void Decode_UnparseableElement_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("[\"abc\"]", DataType.List, DataType.Int32)); + + [Fact] + public void Decode_NullElement_Throws() => + Assert.Throws(() => + AttributeValueCodec.Decode("[null]", DataType.List, DataType.String)); + + [Fact] + public void Decode_List_WithoutElementType_Throws() => + Assert.Throws(() => + 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)); +}