diff --git a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs index 8cfbb7f9..8adbbf71 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs @@ -9,8 +9,31 @@ namespace MTConnect.Streams.Json { + /// + /// Typed representation of a Condition list on a Component stream, + /// bucketed by Condition level (Fault, Warning, Normal, Unavailable). + /// + /// + /// Serialized via in the + /// cppagent JSON v2 wire shape: an array of single-key wrapper + /// objects, one per Condition entry + /// (e.g. [{"Normal": {...}}, {"Warning": {...}}]). + /// The level order on the wire is fixed at Fault, Warning, Normal, + /// Unavailable; mixed-level interleaving is not round-trip preserved + /// through this typed model. The legacy MTConnect JSON v1 + /// object-keyed shape ({"Fault": [...], "Warning": [...], ...}) + /// is still accepted on the read path for back-compat. + /// + [System.Text.Json.Serialization.JsonConverter(typeof(JsonConditionsConverter))] public class JsonConditions { + /// + /// Materializes every level bucket into a flat list of + /// instances, tagged with the + /// corresponding . Enumeration order + /// matches the wire-emission order: Fault, then Warning, then + /// Normal, then Unavailable. + /// [JsonIgnore] public List Observations { @@ -42,15 +65,39 @@ public List Observations } } + /// + /// Condition entries at FAULT level. Source order is + /// preserved within the bucket; entries are emitted on the wire + /// as {"Fault": {...}} wrapper objects, ahead of every + /// other level. + /// [JsonPropertyName("Fault")] public IEnumerable Fault { get; set; } + /// + /// Condition entries at WARNING level. Source order is + /// preserved within the bucket; entries are emitted on the wire + /// as {"Warning": {...}} wrapper objects, after Fault + /// and before Normal. + /// [JsonPropertyName("Warning")] public IEnumerable Warning { get; set; } + /// + /// Condition entries at NORMAL level. Source order is + /// preserved within the bucket; entries are emitted on the wire + /// as {"Normal": {...}} wrapper objects, after Warning + /// and before Unavailable. + /// [JsonPropertyName("Normal")] public IEnumerable Normal { get; set; } + /// + /// Condition entries at UNAVAILABLE level. Source order + /// is preserved within the bucket; entries are emitted on the + /// wire as {"Unavailable": {...}} wrapper objects, after + /// every other level. + /// [JsonPropertyName("Unavailable")] public IEnumerable Unavailable { get; set; } @@ -113,5 +160,5 @@ public JsonConditions(IEnumerable observations) } } } - } -} \ No newline at end of file + } +} diff --git a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionsConverter.cs b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionsConverter.cs new file mode 100644 index 00000000..668d6ae7 --- /dev/null +++ b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionsConverter.cs @@ -0,0 +1,208 @@ +// Copyright (c) 2023 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MTConnect.Streams.Json +{ + // Serializes JsonConditions in the cppagent JSON v2 wire shape: an + // array of single-key wrapper objects, one per Condition entry + // (e.g. [{"Normal": {...}}, {"Warning": {...}}]). The XSD + // ConditionListType is + // of Normal|Warning|Fault|Unavailable. + // + // Ordering: the typed JsonConditions POCO buckets entries by level + // (Fault, Warning, Normal, Unavailable). The Write path always emits + // in that fixed level order (Fault first, then Warning, then Normal, + // then Unavailable), with source order preserved within each bucket. + // Mixed-level interleaving on the wire is therefore NOT round-trip + // preserved: reading [{Fault:f1},{Normal:n1},{Fault:f2}] yields + // Fault=[f1,f2], Normal=[n1] and re-serializes as + // [{Fault:f1},{Fault:f2},{Normal:n1}]. Round-trip byte-identity + // holds only when each level's entries are already contiguous on + // the input wire. + // + // The legacy MTConnect JSON v1 object-keyed shape + // ({"Fault": [...], "Warning": [...], ...}) is still accepted on the + // read path for back-compat. + internal sealed class JsonConditionsConverter : JsonConverter + { + private const string FaultLevel = "Fault"; + private const string WarningLevel = "Warning"; + private const string NormalLevel = "Normal"; + private const string UnavailableLevel = "Unavailable"; + + public override JsonConditions Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + + case JsonTokenType.StartArray: + return ReadArrayShape(ref reader, options); + + case JsonTokenType.StartObject: + return ReadObjectShape(ref reader, options); + + default: + throw new JsonException( + $"Unexpected token '{reader.TokenType}' when reading JsonConditions; expected array, object, or null."); + } + } + + public override void Write(Utf8JsonWriter writer, JsonConditions value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartArray(); + + WriteLevel(writer, FaultLevel, value.Fault, options); + WriteLevel(writer, WarningLevel, value.Warning, options); + WriteLevel(writer, NormalLevel, value.Normal, options); + WriteLevel(writer, UnavailableLevel, value.Unavailable, options); + + writer.WriteEndArray(); + } + + private static void WriteLevel(Utf8JsonWriter writer, string levelName, IEnumerable entries, JsonSerializerOptions options) + { + if (entries == null) return; + + foreach (var entry in entries) + { + writer.WriteStartObject(); + writer.WritePropertyName(levelName); + JsonSerializer.Serialize(writer, entry, options); + writer.WriteEndObject(); + } + } + + private static JsonConditions ReadArrayShape(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var faults = new List(); + var warnings = new List(); + var normals = new List(); + var unavailables = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) break; + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException( + $"Unexpected token '{reader.TokenType}' inside JsonConditions array; expected object wrapper."); + } + + if (!reader.Read() || reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name inside JsonConditions wrapper object."); + } + + var levelName = reader.GetString(); + if (!reader.Read()) + { + throw new JsonException("Expected value after Condition level name in JsonConditions wrapper object."); + } + var entry = JsonSerializer.Deserialize(ref reader, options); + if (entry == null) + { + throw new JsonException("Null Condition entry value in JsonConditions wrapper."); + } + + if (!reader.Read() || reader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException("Expected end of JsonConditions wrapper object after entry."); + } + + switch (levelName) + { + case FaultLevel: + faults.Add(entry); + break; + case WarningLevel: + warnings.Add(entry); + break; + case NormalLevel: + normals.Add(entry); + break; + case UnavailableLevel: + unavailables.Add(entry); + break; + default: + throw new JsonException( + $"Unknown Condition level '{levelName}' in JsonConditions array; expected Fault, Warning, Normal, or Unavailable."); + } + } + + return new JsonConditions + { + Fault = faults.Count > 0 ? faults : null, + Warning = warnings.Count > 0 ? warnings : null, + Normal = normals.Count > 0 ? normals : null, + Unavailable = unavailables.Count > 0 ? unavailables : null, + }; + } + + // Reads the legacy MTConnect JSON v1 object-keyed shape: + // {"Fault": [...], "Warning": [...], ...}. Duplicate level keys + // on the input (e.g. {"Fault":[a],"Fault":[b]}) are by-design + // last-write-wins: each occurrence overwrites the previous + // entry list. This is accepted asymmetry with the array path, + // which appends across all occurrences. Legacy producers that + // emit each level key exactly once are unaffected; the asymmetry + // only surfaces on malformed-or-duplicate legacy input, where + // last-write-wins is a deterministic, documented behavior. + private static JsonConditions ReadObjectShape(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var result = new JsonConditions(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) break; + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException( + $"Unexpected token '{reader.TokenType}' inside JsonConditions object; expected property name."); + } + + var levelName = reader.GetString(); + if (!reader.Read()) + { + throw new JsonException("Expected value after Condition level name in JsonConditions wrapper object."); + } + var entries = JsonSerializer.Deserialize>(ref reader, options); + + switch (levelName) + { + case FaultLevel: + result.Fault = entries; + break; + case WarningLevel: + result.Warning = entries; + break; + case NormalLevel: + result.Normal = entries; + break; + case UnavailableLevel: + result.Unavailable = entries; + break; + default: + throw new JsonException( + $"Unknown Condition level '{levelName}' in JsonConditions object; expected Fault, Warning, Normal, or Unavailable."); + } + } + + return result; + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionsArrayShapeTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionsArrayShapeTests.cs new file mode 100644 index 00000000..5c982d9f --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionsArrayShapeTests.cs @@ -0,0 +1,382 @@ +// Copyright (c) 2023 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Streams.Json; +using NUnit.Framework; +using System.Collections.Generic; +using System.Text.Json; + +namespace MTConnect.NET_JSON_cppagent_Tests.Streams +{ + // Pins the cppagent JSON v2 array-of-wrappers wire shape for + // ConditionListType. The XSD declares ConditionListType as + // + // of Normal|Warning|Fault|Unavailable; cppagent v2 emits one + // single-key wrapper object per entry. + // + // Ordering: the typed JsonConditions POCO buckets entries by level + // (Fault, Warning, Normal, Unavailable). The converter emits in + // that fixed level order, with source order preserved within each + // bucket. Mixed-level interleaving on the wire is therefore NOT + // round-trip preserved through the typed model — see the + // Read_ArrayShape_MixedLevelInterleaving_BucketsByLevel test for + // the explicit pin. + // + // Sources: + // - XSD: https://schemas.mtconnect.org/schemas/MTConnectStreams_2.7.xsd + // (complex type ConditionListType). + // - Prose: MTConnect Standard Part 2 section 13 "Condition". + // - cppagent reference (v2.7.0.7): printer/json_printer.cpp + // function print_condition. + [TestFixture] + public class JsonConditionsArrayShapeTests + { + private static JsonCondition MakeEntry(string dataItemId, string type) + { + return new JsonCondition + { + DataItemId = dataItemId, + Type = type, + }; + } + + private static JsonSerializerOptions Options() => new JsonSerializerOptions(); + + private static string FirstPropertyName(JsonElement element) + { + using var enumerator = element.EnumerateObject(); + enumerator.MoveNext(); + return enumerator.Current.Name; + } + + // Case 1 — empty conditions serialize as the array shape, not an object shape. + [Test] + public void Write_EmptyConditions_EmitsEmptyArray() + { + var conditions = new JsonConditions(); + + var json = JsonSerializer.Serialize(conditions, Options()); + + Assert.That(json, Is.EqualTo("[]")); + } + + // Case 2 — one Normal entry produces a 1-element array with a Normal wrapper. + [Test] + public void Write_SingleNormal_EmitsOneNormalWrapper() + { + var conditions = new JsonConditions + { + Normal = new List { MakeEntry("n1", "TEMPERATURE") }, + }; + + var json = JsonSerializer.Serialize(conditions, Options()); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(root.GetArrayLength(), Is.EqualTo(1)); + + var wrapper = root[0]; + Assert.That(wrapper.ValueKind, Is.EqualTo(JsonValueKind.Object)); + Assert.That(wrapper.TryGetProperty("Normal", out var entry), Is.True); + Assert.That(entry.GetProperty("dataItemId").GetString(), Is.EqualTo("n1")); + } + + // Case 3 — Fault + Warning emit in Fault, Warning order per the converter. + [Test] + public void Write_FaultThenWarning_EmitsInDeclaredEnumerationOrder() + { + var conditions = new JsonConditions + { + Fault = new List { MakeEntry("f1", "TEMPERATURE") }, + Warning = new List { MakeEntry("w1", "POSITION") }, + }; + + var json = JsonSerializer.Serialize(conditions, Options()); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.That(root.GetArrayLength(), Is.EqualTo(2)); + + Assert.That(FirstPropertyName(root[0]), Is.EqualTo("Fault")); + Assert.That(FirstPropertyName(root[1]), Is.EqualTo("Warning")); + } + + // Case 4 — all four levels populated emit in Fault, Warning, Normal, Unavailable order. + [Test] + public void Write_AllFourLevels_EmitsInFaultWarningNormalUnavailableOrder() + { + var conditions = new JsonConditions + { + Fault = new List { MakeEntry("f1", "TEMPERATURE") }, + Warning = new List { MakeEntry("w1", "POSITION") }, + Normal = new List { MakeEntry("n1", "AVAILABILITY") }, + Unavailable = new List { MakeEntry("u1", "ROTATION") }, + }; + + var json = JsonSerializer.Serialize(conditions, Options()); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.That(root.GetArrayLength(), Is.EqualTo(4)); + + var keys = new List(); + for (var i = 0; i < root.GetArrayLength(); i++) + { + foreach (var prop in root[i].EnumerateObject()) + { + keys.Add(prop.Name); + } + } + + Assert.That(keys, Is.EqualTo(new[] { "Fault", "Warning", "Normal", "Unavailable" })); + } + + // Case 5 — multiple entries on one level produce one wrapper each in source order. + [Test] + public void Write_MultipleFaults_EmitsOneWrapperPerEntry() + { + var conditions = new JsonConditions + { + Fault = new List + { + MakeEntry("f1", "TEMPERATURE"), + MakeEntry("f2", "POSITION"), + MakeEntry("f3", "AVAILABILITY"), + }, + }; + + var json = JsonSerializer.Serialize(conditions, Options()); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.That(root.GetArrayLength(), Is.EqualTo(3)); + + var ids = new List(); + foreach (var element in root.EnumerateArray()) + { + Assert.That(element.TryGetProperty("Fault", out var entry), Is.True); + ids.Add(entry.GetProperty("dataItemId").GetString()!); + } + + Assert.That(ids, Is.EqualTo(new[] { "f1", "f2", "f3" })); + } + + // Case 5b — mixed-level interleaving on the input wire is bucketed + // by level on read and re-emitted in level order (Fault, Warning, + // Normal, Unavailable) on write. Pins the documented non-byte- + // identical round-trip for interleaved input — see the type + // comment on JsonConditionsConverter for the design rationale. + [Test] + public void Read_ArrayShape_MixedLevelInterleaving_BucketsByLevel() + { + const string interleaved = + "[{\"Fault\":{\"dataItemId\":\"f1\"}}," + + "{\"Normal\":{\"dataItemId\":\"n1\"}}," + + "{\"Fault\":{\"dataItemId\":\"f2\"}}]"; + + var parsed = JsonSerializer.Deserialize(interleaved, Options()); + + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed!.Fault, Is.Not.Null); + Assert.That(parsed.Normal, Is.Not.Null); + Assert.That(parsed.Warning, Is.Null); + Assert.That(parsed.Unavailable, Is.Null); + + var faultIds = new List(); + foreach (var entry in parsed.Fault!) faultIds.Add(entry.DataItemId); + Assert.That(faultIds, Is.EqualTo(new[] { "f1", "f2" })); + + var normalIds = new List(); + foreach (var entry in parsed.Normal!) normalIds.Add(entry.DataItemId); + Assert.That(normalIds, Is.EqualTo(new[] { "n1" })); + + var rewritten = JsonSerializer.Serialize(parsed, Options()); + using var rewrittenDoc = JsonDocument.Parse(rewritten); + var rewrittenRoot = rewrittenDoc.RootElement; + Assert.That(rewrittenRoot.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(rewrittenRoot.GetArrayLength(), Is.EqualTo(3)); + + var rewrittenKeys = new List(); + var rewrittenDataItemIds = new List(); + for (var i = 0; i < rewrittenRoot.GetArrayLength(); i++) + { + foreach (var prop in rewrittenRoot[i].EnumerateObject()) + { + rewrittenKeys.Add(prop.Name); + rewrittenDataItemIds.Add(prop.Value.GetProperty("dataItemId").GetString()!); + } + } + + Assert.That(rewrittenKeys, Is.EqualTo(new[] { "Fault", "Fault", "Normal" })); + Assert.That(rewrittenDataItemIds, Is.EqualTo(new[] { "f1", "f2", "n1" })); + } + + // Case 6 — array JSON round-trips through Deserialize/Serialize without drift. + [Test] + public void RoundTrip_ArrayShape_IsByteIdenticalModuloWhitespace() + { + var original = new JsonConditions + { + Fault = new List { MakeEntry("f1", "TEMPERATURE") }, + Warning = new List { MakeEntry("w1", "POSITION") }, + Normal = new List { MakeEntry("n1", "AVAILABILITY") }, + Unavailable = new List { MakeEntry("u1", "ROTATION") }, + }; + + var json = JsonSerializer.Serialize(original, Options()); + var parsed = JsonSerializer.Deserialize(json, Options()); + var json2 = JsonSerializer.Serialize(parsed, Options()); + + Assert.That(json2, Is.EqualTo(json)); + } + + // Case 7 — legacy MTConnect JSON v1 object-keyed shape parses into the typed POCO. + [Test] + public void Read_LegacyObjectShape_PopulatesTypedProperties() + { + const string legacy = + "{\"Normal\":[{\"dataItemId\":\"n1\",\"type\":\"TEMPERATURE\"}]," + + "\"Fault\":[{\"dataItemId\":\"f1\",\"type\":\"POSITION\"}]}"; + + var parsed = JsonSerializer.Deserialize(legacy, Options()); + + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed!.Normal, Is.Not.Null); + Assert.That(parsed.Fault, Is.Not.Null); + + using var normalEnumerator = parsed.Normal!.GetEnumerator(); + Assert.That(normalEnumerator.MoveNext(), Is.True); + Assert.That(normalEnumerator.Current.DataItemId, Is.EqualTo("n1")); + + using var faultEnumerator = parsed.Fault!.GetEnumerator(); + Assert.That(faultEnumerator.MoveNext(), Is.True); + Assert.That(faultEnumerator.Current.DataItemId, Is.EqualTo("f1")); + } + + // Case 8 — null write emits "null" and round-trips back to a null reference. + [Test] + public void Null_WriteAndRead_RoundTripsToNull() + { + var json = JsonSerializer.Serialize(null!, Options()); + Assert.That(json, Is.EqualTo("null")); + + var parsed = JsonSerializer.Deserialize("null", Options()); + Assert.That(parsed, Is.Null); + } + + // Case 9 — invalid root token (number) raises JsonException with a recognisable message. + [Test] + public void Read_InvalidRootToken_ThrowsJsonException() + { + var ex = Assert.Throws(() => + JsonSerializer.Deserialize("123", Options())); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain("Unexpected token")); + } + + // Coverage filler — the array-shape read path also handles all four levels + // and rejects unknown level names + malformed wrapper objects. + [Test] + public void Read_ArrayShape_PopulatesAllFourLevels() + { + const string json = + "[{\"Fault\":{\"dataItemId\":\"f1\"}}," + + "{\"Warning\":{\"dataItemId\":\"w1\"}}," + + "{\"Normal\":{\"dataItemId\":\"n1\"}}," + + "{\"Unavailable\":{\"dataItemId\":\"u1\"}}]"; + + var parsed = JsonSerializer.Deserialize(json, Options()); + + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed!.Fault, Is.Not.Null); + Assert.That(parsed.Warning, Is.Not.Null); + Assert.That(parsed.Normal, Is.Not.Null); + Assert.That(parsed.Unavailable, Is.Not.Null); + } + + [Test] + public void Read_ArrayShape_UnknownLevel_ThrowsJsonException() + { + const string json = "[{\"Bogus\":{\"dataItemId\":\"x1\"}}]"; + + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, Options())); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain("Unknown Condition level")); + } + + [Test] + public void Read_ArrayShape_NonObjectElement_ThrowsJsonException() + { + const string json = "[42]"; + + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, Options())); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain("expected object wrapper")); + } + + [Test] + public void Read_ArrayShape_WrapperWithoutPropertyName_ThrowsJsonException() + { + const string json = "[{}]"; + + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, Options())); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain("Expected property name")); + } + + [Test] + public void Read_ArrayShape_WrapperWithMultipleProperties_ThrowsJsonException() + { + const string json = "[{\"Fault\":{\"dataItemId\":\"f1\"},\"Warning\":{\"dataItemId\":\"w1\"}}]"; + + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, Options())); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain("end of JsonConditions wrapper")); + } + + [Test] + public void Read_ArrayShape_NullEntry_ThrowsJsonException() + { + const string json = "[{\"Normal\":null}]"; + + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, Options())); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain("Null Condition entry")); + } + + [Test] + public void Read_ObjectShape_UnknownLevel_ThrowsJsonException() + { + const string json = "{\"Bogus\":[{\"dataItemId\":\"x1\"}]}"; + + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, Options())); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Message, Does.Contain("Unknown Condition level")); + } + + [Test] + public void Read_ObjectShape_PopulatesAllFourLevels() + { + const string json = + "{\"Fault\":[{\"dataItemId\":\"f1\"}]," + + "\"Warning\":[{\"dataItemId\":\"w1\"}]," + + "\"Normal\":[{\"dataItemId\":\"n1\"}]," + + "\"Unavailable\":[{\"dataItemId\":\"u1\"}]}"; + + var parsed = JsonSerializer.Deserialize(json, Options()); + + Assert.That(parsed, Is.Not.Null); + Assert.That(parsed!.Fault, Is.Not.Null); + Assert.That(parsed.Warning, Is.Not.Null); + Assert.That(parsed.Normal, Is.Not.Null); + Assert.That(parsed.Unavailable, Is.Not.Null); + } + } +}