diff --git a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionWrapper.cs b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionWrapper.cs new file mode 100644 index 000000000..5f4487e27 --- /dev/null +++ b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionWrapper.cs @@ -0,0 +1,132 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using MTConnect.Observations; +using System.Text.Json.Serialization; + +namespace MTConnect.Streams.Json +{ + /// + /// One Condition observation as it appears on the wire in the cppagent + /// JSON v2 shape: an object with exactly one of , + /// , , or + /// set and the other three . The three null members + /// are suppressed on serialization by + /// , producing the + /// single-key envelope cppagent emits (e.g. {"Normal": {...}}). + /// + /// + /// The four properties are data-carriers only: setting more than + /// one at a time produces a wire shape cppagent will reject on read but + /// the type does not enforce single-key invariants at runtime. Callers + /// should prefer the Of* factory methods, which construct a + /// wrapper with exactly one property populated. + /// + /// and are convenience read-side + /// accessors for consumers that iterate a + /// list without pattern-matching on which property is non-null; both are + /// suppressed from serialization. + /// + /// + public sealed class JsonConditionWrapper + { + /// + /// Condition entry at FAULT level, or + /// when this wrapper carries a different level. Serializes as the + /// wire property Fault. + /// + [JsonPropertyName("Fault")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonCondition Fault { get; set; } + + /// + /// Condition entry at WARNING level, or + /// when this wrapper carries a different level. Serializes as the + /// wire property Warning. + /// + [JsonPropertyName("Warning")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonCondition Warning { get; set; } + + /// + /// Condition entry at NORMAL level, or + /// when this wrapper carries a different level. Serializes as the + /// wire property Normal. + /// + [JsonPropertyName("Normal")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonCondition Normal { get; set; } + + /// + /// Condition entry at UNAVAILABLE level, or + /// when this wrapper carries a different level. Serializes as the + /// wire property Unavailable. + /// + [JsonPropertyName("Unavailable")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonCondition Unavailable { get; set; } + + /// + /// The single non-null condition carried by this wrapper, or + /// when the wrapper is empty. Precedence + /// order on multi-populated wrappers is + /// Fault → Warning → Normal → Unavailable, matching + /// . Never serialized. + /// + [JsonIgnore] + public JsonCondition Value => Fault ?? Warning ?? Normal ?? Unavailable; + + /// + /// The wire property name of the single non-null level carried by + /// this wrapper ("Fault", "Warning", "Normal", + /// or "Unavailable"), or when the + /// wrapper is empty. Never serialized. + /// + [JsonIgnore] + public string Level => + Fault != null ? "Fault" : + Warning != null ? "Warning" : + Normal != null ? "Normal" : + Unavailable != null ? "Unavailable" : null; + + /// + /// Constructs a wrapper carrying the given condition at + /// FAULT level. + /// + public static JsonConditionWrapper OfFault(JsonCondition condition) => new JsonConditionWrapper { Fault = condition }; + + /// + /// Constructs a wrapper carrying the given condition at + /// WARNING level. + /// + public static JsonConditionWrapper OfWarning(JsonCondition condition) => new JsonConditionWrapper { Warning = condition }; + + /// + /// Constructs a wrapper carrying the given condition at + /// NORMAL level. + /// + public static JsonConditionWrapper OfNormal(JsonCondition condition) => new JsonConditionWrapper { Normal = condition }; + + /// + /// Constructs a wrapper carrying the given condition at + /// UNAVAILABLE level. + /// + public static JsonConditionWrapper OfUnavailable(JsonCondition condition) => new JsonConditionWrapper { Unavailable = condition }; + + /// + /// Materializes this wrapper's condition into a strongly-typed + /// at the level indicated by + /// which property is non-null, or when the + /// wrapper is empty. Precedence on multi-populated wrappers + /// matches . + /// + public IConditionObservation ToObservation() + { + if (Fault != null) return Fault.ToCondition(ConditionLevel.FAULT); + if (Warning != null) return Warning.ToCondition(ConditionLevel.WARNING); + if (Normal != null) return Normal.ToCondition(ConditionLevel.NORMAL); + if (Unavailable != null) return Unavailable.ToCondition(ConditionLevel.UNAVAILABLE); + return null; + } + } +} diff --git a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs index 678f16357..4bb8ed733 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditions.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023 TrakHound Inc., All Rights Reserved. +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. // TrakHound Inc. licenses this file to you under the MIT license. using MTConnect.Observations; @@ -10,163 +10,99 @@ namespace MTConnect.Streams.Json { /// - /// Typed representation of a Condition list on a Component stream, - /// bucketed by Condition level (Fault, Warning, Normal, Unavailable). + /// Sequence of Condition observations in the cppagent JSON v2 shape: + /// a flat JSON array of single-key + /// objects, one wrapper per observation, in insertion order. /// /// - /// 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. + /// The type shape IS the wire shape — + /// inherits of + /// so System.Text.Json's default serializer handles both directions + /// with no custom converter. Emitting bytes: + /// [{"Normal":{...}},{"Warning":{...}},{"Fault":{...}}] + /// requires only that each wrapper carries exactly one non-null + /// property (enforced by convention, not by runtime validation) and + /// that the four properties on are + /// annotated with + /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]. + /// + /// Element ordering on the wire follows list insertion order. The + /// ctor + /// preserves the historical level-order emission + /// (FAULTWARNINGNORMALUNAVAILABLE, + /// source order within each level) so this rewrite is byte-identical + /// on the wire to the pre-refactor converter output for the same + /// observation input; callers constructing via the copy ctor or + /// direct control ordering explicitly. + /// /// - [System.Text.Json.Serialization.JsonConverter(typeof(JsonConditionsConverter))] - public class JsonConditions + public sealed class JsonConditions : List { /// - /// 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. + /// Initializes an empty container for JSON deserialization or + /// programmatic construction via . /// - [JsonIgnore] - public List Observations - { - get - { - var l = new List(); - - if (!Fault.IsNullOrEmpty()) - { - foreach (var x in Fault) l.Add(x.ToCondition(ConditionLevel.FAULT)); - } - - if (!Warning.IsNullOrEmpty()) - { - foreach (var x in Warning) l.Add(x.ToCondition(ConditionLevel.WARNING)); - } - - if (!Normal.IsNullOrEmpty()) - { - foreach (var x in Normal) l.Add(x.ToCondition(ConditionLevel.NORMAL)); - } - - if (!Unavailable.IsNullOrEmpty()) - { - foreach (var x in Unavailable) l.Add(x.ToCondition(ConditionLevel.UNAVAILABLE)); - } - - return l; - } - } - - /// - /// 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; } + public JsonConditions() { } /// - /// 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. + /// Initializes the container with a pre-built sequence of + /// wrappers. Order is preserved from + /// . /// - [JsonPropertyName("Normal")] - public IEnumerable Normal { get; set; } + public JsonConditions(IEnumerable wrappers) + : base(wrappers ?? Enumerable.Empty()) + { + } /// - /// 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. + /// Initializes the container from an observation-output + /// sequence, wrapping each observation in the single-key wrapper + /// for its level. Preserves the historical level-order emission + /// (FAULTWARNINGNORMALUNAVAILABLE, + /// source order within each level) so the wire output is + /// byte-identical to the pre-refactor converter for the same + /// input. /// - [JsonPropertyName("Unavailable")] - public IEnumerable Unavailable { get; set; } - + public JsonConditions(IEnumerable observations) + { + if (observations == null) return; - /// - /// Initializes an empty instance for JSON deserialization. - /// - public JsonConditions() { } + AppendLevel(observations, ConditionLevel.FAULT, JsonConditionWrapper.OfFault); + AppendLevel(observations, ConditionLevel.WARNING, JsonConditionWrapper.OfWarning); + AppendLevel(observations, ConditionLevel.NORMAL, JsonConditionWrapper.OfNormal); + AppendLevel(observations, ConditionLevel.UNAVAILABLE, JsonConditionWrapper.OfUnavailable); + } /// - /// Initializes the container from an observation-output - /// sequence, partitioning each observation into the - /// Fault/Warning/Normal/Unavailable bucket indicated by its - /// Level value-bag entry. + /// Materializes every wrapper into a flat + /// of , in list + /// insertion order. Wrappers whose four level properties are all + /// are skipped. Not serialized. /// - public JsonConditions(IEnumerable observations) + [JsonIgnore] + public List Observations { - if (observations != null) + get { - if (!observations.IsNullOrEmpty()) + var result = new List(Count); + foreach (var wrapper in this) { - // Add Fault - var levelObservations = observations.Where(o => o.GetValue(ValueKeys.Level) == ConditionLevel.FAULT.ToString()); - if (!levelObservations.IsNullOrEmpty()) - { - var jsonObservations = new List(); - foreach (var observation in levelObservations) - { - jsonObservations.Add(new JsonCondition(observation)); - } - Fault = jsonObservations; - } - - // Add Warning - levelObservations = observations.Where(o => o.GetValue(ValueKeys.Level) == ConditionLevel.WARNING.ToString()); - if (!levelObservations.IsNullOrEmpty()) - { - var jsonObservations = new List(); - foreach (var observation in levelObservations) - { - jsonObservations.Add(new JsonCondition(observation)); - } - Warning = jsonObservations; - } - - // Add Normal - levelObservations = observations.Where(o => o.GetValue(ValueKeys.Level) == ConditionLevel.NORMAL.ToString()); - if (!levelObservations.IsNullOrEmpty()) - { - var jsonObservations = new List(); - foreach (var observation in levelObservations) - { - jsonObservations.Add(new JsonCondition(observation)); - } - Normal = jsonObservations; - } - - // Add Unavailable - levelObservations = observations.Where(o => o.GetValue(ValueKeys.Level) == ConditionLevel.UNAVAILABLE.ToString()); - if (!levelObservations.IsNullOrEmpty()) - { - var jsonObservations = new List(); - foreach (var observation in levelObservations) - { - jsonObservations.Add(new JsonCondition(observation)); - } - Unavailable = jsonObservations; - } + var observation = wrapper?.ToObservation(); + if (observation != null) result.Add(observation); } + return result; + } + } + + private void AppendLevel( + IEnumerable observations, + ConditionLevel level, + System.Func factory) + { + var levelName = level.ToString(); + foreach (var observation in observations.Where(o => o != null && o.GetValue(ValueKeys.Level) == levelName)) + { + Add(factory(new JsonCondition(observation))); } } } diff --git a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionsConverter.cs b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionsConverter.cs deleted file mode 100644 index 668d6ae77..000000000 --- a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonConditionsConverter.cs +++ /dev/null @@ -1,208 +0,0 @@ -// 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/JsonConditionWrapperTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionWrapperTests.cs new file mode 100644 index 000000000..19d6d6f68 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionWrapperTests.cs @@ -0,0 +1,376 @@ +// Copyright (c) 2026 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; +using System.Text.Json.Serialization; + +namespace MTConnect.NET_JSON_cppagent_Tests.Streams +{ + /// + /// Unit coverage for — the + /// single-Condition envelope carried by + /// in the cppagent JSON v2 wire shape. + /// + [TestFixture] + public class JsonConditionWrapperTests + { + private static JsonCondition NewCondition(string dataItemId, string? type = null) => + new JsonCondition { DataItemId = dataItemId, Type = type! }; + + /// + /// Options that suppress null-valued properties on the wire, matching + /// the cppagent JSON v2 sparse-object shape (dataItemId + populated + /// fields only). Required for byte-identical wire assertions because + /// 's properties do not carry per-property + /// [JsonIgnore(WhenWritingNull)]; the null-suppression contract + /// lives on the emission pipeline's options object, matching how the + /// pre-refactor converter also relied on caller-supplied options. + /// + private static JsonSerializerOptions SparseOptions() => + new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + + // ------------------------------------------------------------------ + // Factory methods — each populates exactly one level, leaves the + // other three null so the wire envelope is single-key on serialize. + // ------------------------------------------------------------------ + + /// Pins the OfFault factory contract: only Fault set, others null. + [Test] + public void OfFault_populates_only_Fault() + { + var c = NewCondition("f1"); + + var wrapper = JsonConditionWrapper.OfFault(c); + + Assert.That(wrapper.Fault, Is.SameAs(c)); + Assert.That(wrapper.Warning, Is.Null); + Assert.That(wrapper.Normal, Is.Null); + Assert.That(wrapper.Unavailable, Is.Null); + } + + /// Pins the OfWarning factory contract: only Warning set, others null. + [Test] + public void OfWarning_populates_only_Warning() + { + var c = NewCondition("w1"); + + var wrapper = JsonConditionWrapper.OfWarning(c); + + Assert.That(wrapper.Fault, Is.Null); + Assert.That(wrapper.Warning, Is.SameAs(c)); + Assert.That(wrapper.Normal, Is.Null); + Assert.That(wrapper.Unavailable, Is.Null); + } + + /// Pins the OfNormal factory contract: only Normal set, others null. + [Test] + public void OfNormal_populates_only_Normal() + { + var c = NewCondition("n1"); + + var wrapper = JsonConditionWrapper.OfNormal(c); + + Assert.That(wrapper.Fault, Is.Null); + Assert.That(wrapper.Warning, Is.Null); + Assert.That(wrapper.Normal, Is.SameAs(c)); + Assert.That(wrapper.Unavailable, Is.Null); + } + + /// Pins the OfUnavailable factory contract: only Unavailable set, others null. + [Test] + public void OfUnavailable_populates_only_Unavailable() + { + var c = NewCondition("u1"); + + var wrapper = JsonConditionWrapper.OfUnavailable(c); + + Assert.That(wrapper.Fault, Is.Null); + Assert.That(wrapper.Warning, Is.Null); + Assert.That(wrapper.Normal, Is.Null); + Assert.That(wrapper.Unavailable, Is.SameAs(c)); + } + + // ------------------------------------------------------------------ + // Value accessor — returns the single non-null condition, with + // precedence Fault > Warning > Normal > Unavailable on multi-populated + // wrappers, and null on empty. + // ------------------------------------------------------------------ + + /// Pins Value on an empty wrapper: returns null. + [Test] + public void Value_is_null_on_empty_wrapper() + { + Assert.That(new JsonConditionWrapper().Value, Is.Null); + } + + /// Pins Value returns the Fault condition when Fault is set. + [Test] + public void Value_returns_Fault_when_only_Fault_set() + { + var c = NewCondition("f1"); + Assert.That(JsonConditionWrapper.OfFault(c).Value, Is.SameAs(c)); + } + + /// Pins Value returns the Warning condition when only Warning is set. + [Test] + public void Value_returns_Warning_when_only_Warning_set() + { + var c = NewCondition("w1"); + Assert.That(JsonConditionWrapper.OfWarning(c).Value, Is.SameAs(c)); + } + + /// Pins Value returns the Normal condition when only Normal is set. + [Test] + public void Value_returns_Normal_when_only_Normal_set() + { + var c = NewCondition("n1"); + Assert.That(JsonConditionWrapper.OfNormal(c).Value, Is.SameAs(c)); + } + + /// Pins Value returns the Unavailable condition when only Unavailable is set. + [Test] + public void Value_returns_Unavailable_when_only_Unavailable_set() + { + var c = NewCondition("u1"); + Assert.That(JsonConditionWrapper.OfUnavailable(c).Value, Is.SameAs(c)); + } + + /// Pins the Fault>Warning>Normal>Unavailable precedence on multi-populated wrappers. + [Test] + public void Value_precedence_is_Fault_Warning_Normal_Unavailable() + { + var f = NewCondition("f1"); + var w = NewCondition("w1"); + var n = NewCondition("n1"); + var u = NewCondition("u1"); + + Assert.That(new JsonConditionWrapper { Fault = f, Warning = w, Normal = n, Unavailable = u }.Value, Is.SameAs(f)); + Assert.That(new JsonConditionWrapper { Warning = w, Normal = n, Unavailable = u }.Value, Is.SameAs(w)); + Assert.That(new JsonConditionWrapper { Normal = n, Unavailable = u }.Value, Is.SameAs(n)); + Assert.That(new JsonConditionWrapper { Unavailable = u }.Value, Is.SameAs(u)); + } + + // ------------------------------------------------------------------ + // Level accessor — returns the wire property name, matching Value's + // precedence, and null on empty. + // ------------------------------------------------------------------ + + /// Pins Level == null on an empty wrapper. + [Test] + public void Level_is_null_on_empty_wrapper() + { + Assert.That(new JsonConditionWrapper().Level, Is.Null); + } + + /// Pins Level == "Fault" when only Fault is set. + [Test] + public void Level_is_Fault_when_only_Fault_set() + { + Assert.That(JsonConditionWrapper.OfFault(NewCondition("f1")).Level, Is.EqualTo("Fault")); + } + + /// Pins Level == "Warning" when only Warning is set. + [Test] + public void Level_is_Warning_when_only_Warning_set() + { + Assert.That(JsonConditionWrapper.OfWarning(NewCondition("w1")).Level, Is.EqualTo("Warning")); + } + + /// Pins Level == "Normal" when only Normal is set. + [Test] + public void Level_is_Normal_when_only_Normal_set() + { + Assert.That(JsonConditionWrapper.OfNormal(NewCondition("n1")).Level, Is.EqualTo("Normal")); + } + + /// Pins Level == "Unavailable" when only Unavailable is set. + [Test] + public void Level_is_Unavailable_when_only_Unavailable_set() + { + Assert.That(JsonConditionWrapper.OfUnavailable(NewCondition("u1")).Level, Is.EqualTo("Unavailable")); + } + + // ------------------------------------------------------------------ + // ToObservation — materializes the single non-null level into a + // strongly-typed ConditionObservation at the matching level enum. + // ------------------------------------------------------------------ + + /// Pins ToObservation returns null on an empty wrapper. + [Test] + public void ToObservation_returns_null_on_empty_wrapper() + { + Assert.That(new JsonConditionWrapper().ToObservation(), Is.Null); + } + + /// Pins ToObservation returns a FAULT-level condition when Fault is set. + [Test] + public void ToObservation_returns_FAULT_when_Fault_set() + { + var observation = JsonConditionWrapper.OfFault(NewCondition("f1", "TEMPERATURE")).ToObservation(); + Assert.That(observation, Is.Not.Null); + Assert.That(observation.DataItemId, Is.EqualTo("f1")); + Assert.That((observation as MTConnect.Observations.IConditionObservation)!.Level, Is.EqualTo(MTConnect.Observations.ConditionLevel.FAULT)); + } + + /// Pins ToObservation returns a WARNING-level condition when Warning is set. + [Test] + public void ToObservation_returns_WARNING_when_Warning_set() + { + var observation = JsonConditionWrapper.OfWarning(NewCondition("w1", "POSITION")).ToObservation(); + Assert.That(observation, Is.Not.Null); + Assert.That(observation.DataItemId, Is.EqualTo("w1")); + Assert.That((observation as MTConnect.Observations.IConditionObservation)!.Level, Is.EqualTo(MTConnect.Observations.ConditionLevel.WARNING)); + } + + /// Pins ToObservation returns a NORMAL-level condition when Normal is set. + [Test] + public void ToObservation_returns_NORMAL_when_Normal_set() + { + var observation = JsonConditionWrapper.OfNormal(NewCondition("n1", "AVAILABILITY")).ToObservation(); + Assert.That(observation, Is.Not.Null); + Assert.That(observation.DataItemId, Is.EqualTo("n1")); + Assert.That((observation as MTConnect.Observations.IConditionObservation)!.Level, Is.EqualTo(MTConnect.Observations.ConditionLevel.NORMAL)); + } + + /// Pins ToObservation returns an UNAVAILABLE-level condition when Unavailable is set. + [Test] + public void ToObservation_returns_UNAVAILABLE_when_Unavailable_set() + { + var observation = JsonConditionWrapper.OfUnavailable(NewCondition("u1", "ROTATION")).ToObservation(); + Assert.That(observation, Is.Not.Null); + Assert.That(observation.DataItemId, Is.EqualTo("u1")); + Assert.That((observation as MTConnect.Observations.IConditionObservation)!.Level, Is.EqualTo(MTConnect.Observations.ConditionLevel.UNAVAILABLE)); + } + + // ------------------------------------------------------------------ + // Serialization — the single-key envelope on the wire, with the + // three null members suppressed by [JsonIgnore(WhenWritingNull)]. + // ------------------------------------------------------------------ + + /// Pins the single-key wire envelope: only the non-null level property is emitted, and the three sibling level properties are absent from the root object. + [Test] + public void Serialize_wrapper_emits_only_the_non_null_level() + { + var wrapper = JsonConditionWrapper.OfNormal(NewCondition("n1")); + + var json = JsonSerializer.Serialize(wrapper, SparseOptions()); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Object)); + + // Exactly one root property, and it's Normal (not Fault/Warning/Unavailable). + var rootProperties = new List(); + foreach (var prop in root.EnumerateObject()) rootProperties.Add(prop.Name); + Assert.That(rootProperties, Is.EqualTo(new[] { "Normal" })); + + Assert.That(root.TryGetProperty("Normal", out var normal), Is.True); + Assert.That(normal.GetProperty("dataItemId").GetString(), Is.EqualTo("n1")); + } + + /// Pins that Value and Level are not serialized to the wire. + [Test] + public void Serialize_wrapper_omits_convenience_accessors() + { + var wrapper = JsonConditionWrapper.OfFault(NewCondition("f1")); + + var json = JsonSerializer.Serialize(wrapper, SparseOptions()); + + Assert.That(json, Does.Not.Contain("\"Value\"")); + Assert.That(json, Does.Not.Contain("\"Level\"")); + } + + /// Pins that an empty wrapper serializes to {} since every property is suppressed as null. + [Test] + public void Serialize_empty_wrapper_emits_empty_object() + { + Assert.That(JsonSerializer.Serialize(new JsonConditionWrapper(), SparseOptions()), Is.EqualTo("{}")); + } + + /// + /// Documents that a wrapper with multiple non-null level properties + /// emits ALL non-null members. The type does not enforce single-key + /// invariants at runtime; callers should prefer the Of* + /// factories to guarantee a single-key envelope. + /// + [Test] + public void Serialize_multi_populated_wrapper_emits_all_non_null_levels() + { + var wrapper = new JsonConditionWrapper + { + Fault = NewCondition("f1"), + Warning = NewCondition("w1"), + }; + + var json = JsonSerializer.Serialize(wrapper, SparseOptions()); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.That(root.TryGetProperty("Fault", out var fault), Is.True); + Assert.That(fault.GetProperty("dataItemId").GetString(), Is.EqualTo("f1")); + Assert.That(root.TryGetProperty("Warning", out var warning), Is.True); + Assert.That(warning.GetProperty("dataItemId").GetString(), Is.EqualTo("w1")); + Assert.That(root.TryGetProperty("Normal", out _), Is.False); + Assert.That(root.TryGetProperty("Unavailable", out _), Is.False); + } + + // ------------------------------------------------------------------ + // Deserialization — each of the four single-key envelopes populates + // the matching property and leaves the other three null. + // ------------------------------------------------------------------ + + /// Pins that a {"Fault":…} envelope populates Fault only. + [Test] + public void Deserialize_Fault_envelope_populates_only_Fault() + { + var wrapper = JsonSerializer.Deserialize("{\"Fault\":{\"dataItemId\":\"f1\"}}"); + + Assert.That(wrapper, Is.Not.Null); + Assert.That(wrapper!.Fault, Is.Not.Null); + Assert.That(wrapper.Fault.DataItemId, Is.EqualTo("f1")); + Assert.That(wrapper.Warning, Is.Null); + Assert.That(wrapper.Normal, Is.Null); + Assert.That(wrapper.Unavailable, Is.Null); + Assert.That(wrapper.Level, Is.EqualTo("Fault")); + } + + /// Pins that a {"Warning":…} envelope populates Warning only. + [Test] + public void Deserialize_Warning_envelope_populates_only_Warning() + { + var wrapper = JsonSerializer.Deserialize("{\"Warning\":{\"dataItemId\":\"w1\"}}"); + + Assert.That(wrapper!.Warning, Is.Not.Null); + Assert.That(wrapper.Fault, Is.Null); + Assert.That(wrapper.Normal, Is.Null); + Assert.That(wrapper.Unavailable, Is.Null); + } + + /// Pins that a {"Normal":…} envelope populates Normal only. + [Test] + public void Deserialize_Normal_envelope_populates_only_Normal() + { + var wrapper = JsonSerializer.Deserialize("{\"Normal\":{\"dataItemId\":\"n1\"}}"); + + Assert.That(wrapper!.Normal, Is.Not.Null); + Assert.That(wrapper.Fault, Is.Null); + Assert.That(wrapper.Warning, Is.Null); + Assert.That(wrapper.Unavailable, Is.Null); + } + + /// Pins that a {"Unavailable":…} envelope populates Unavailable only. + [Test] + public void Deserialize_Unavailable_envelope_populates_only_Unavailable() + { + var wrapper = JsonSerializer.Deserialize("{\"Unavailable\":{\"dataItemId\":\"u1\"}}"); + + Assert.That(wrapper!.Unavailable, Is.Not.Null); + Assert.That(wrapper.Fault, Is.Null); + Assert.That(wrapper.Warning, Is.Null); + Assert.That(wrapper.Normal, Is.Null); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionsArrayShapeTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionsArrayShapeTests.cs index f62e62c36..219429d28 100644 --- a/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionsArrayShapeTests.cs +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonConditionsArrayShapeTests.cs @@ -1,10 +1,15 @@ -// Copyright (c) 2023 TrakHound Inc., All Rights Reserved. +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. // TrakHound Inc. licenses this file to you under the MIT license. +using MTConnect.Devices; +using MTConnect.Observations; +using MTConnect.Observations.Output; using MTConnect.Streams.Json; using NUnit.Framework; +using System; using System.Collections.Generic; using System.Text.Json; +using System.Text.Json.Serialization; namespace MTConnect.NET_JSON_cppagent_Tests.Streams { @@ -14,13 +19,12 @@ namespace MTConnect.NET_JSON_cppagent_Tests.Streams // 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. + // Since 7.0 the type shape IS the wire shape: JsonConditions + // inherits List and the default S.T.J + // serializer handles both directions with no custom converter. + // Ordering on the wire follows list insertion order; the + // observation-taking ctor preserves the historical + // Fault -> Warning -> Normal -> Unavailable emission order. // // Sources: // - XSD: https://schemas.mtconnect.org/schemas/MTConnectStreams_2.7.xsd @@ -28,374 +32,473 @@ namespace MTConnect.NET_JSON_cppagent_Tests.Streams // - Prose: MTConnect Standard Part 2 section 13 "Condition". // - cppagent reference (v2.7.0.7): printer/json_printer.cpp // function print_condition. - /// Pins the behaviour expressed by the test name: json conditions array shape tests. + /// + /// Unit + wire coverage for — the + /// list-of-wrappers container that emits cppagent JSON v2 shape by + /// default S.T.J behavior on the derived . + /// [TestFixture] public class JsonConditionsArrayShapeTests { - private static JsonCondition MakeEntry(string dataItemId, string type) + private static JsonCondition MakeEntry(string dataItemId, string? type = null) => + new JsonCondition { DataItemId = dataItemId, Type = type! }; + + /// + /// Options that suppress null-valued properties on the wire, matching + /// cppagent JSON v2 sparse-object shape (dataItemId + populated + /// fields only). Required for byte-identical wire assertions because + /// 's inner properties do not carry + /// per-property [JsonIgnore(WhenWritingNull)]; the + /// null-suppression contract lives on the emission pipeline's + /// options object. + /// + private static JsonSerializerOptions SparseOptions() => + new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + + // ------------------------------------------------------------------ + // Ctor coverage — three overloads. + // ------------------------------------------------------------------ + + /// Pins the parameterless ctor: creates an empty list. + [Test] + public void Ctor_Default_creates_empty_list() + { + var conditions = new JsonConditions(); + Assert.That(conditions.Count, Is.EqualTo(0)); + } + + /// Pins the copy ctor: seeds the list from an existing sequence, order preserved. + [Test] + public void Ctor_FromWrapperSequence_seeds_in_insertion_order() { - return new JsonCondition + var wrappers = new[] { - DataItemId = dataItemId, - Type = type, + JsonConditionWrapper.OfNormal (MakeEntry("n1")), + JsonConditionWrapper.OfFault (MakeEntry("f1")), + JsonConditionWrapper.OfWarning(MakeEntry("w1")), }; + + var conditions = new JsonConditions(wrappers); + + Assert.That(conditions.Count, Is.EqualTo(3)); + Assert.That(conditions[0].Level, Is.EqualTo("Normal")); + Assert.That(conditions[1].Level, Is.EqualTo("Fault")); + Assert.That(conditions[2].Level, Is.EqualTo("Warning")); } - private static JsonSerializerOptions Options() => new JsonSerializerOptions(); + /// Pins that a null wrapper-sequence ctor argument yields an empty list, not a NullReferenceException. + [Test] + public void Ctor_FromWrapperSequence_null_is_treated_as_empty() + { + var conditions = new JsonConditions((IEnumerable)null!); + Assert.That(conditions.Count, Is.EqualTo(0)); + } - private static string FirstPropertyName(JsonElement element) + /// Pins that a null observation-sequence ctor argument yields an empty list, not a NullReferenceException. + [Test] + public void Ctor_FromObservationSequence_null_is_treated_as_empty() { - using var enumerator = element.EnumerateObject(); - enumerator.MoveNext(); - return enumerator.Current.Name; + var conditions = new JsonConditions((IEnumerable)null!); + Assert.That(conditions.Count, Is.EqualTo(0)); } - // Case 1 — empty conditions serialize as the array shape, not an object shape. - /// Pins the behaviour expressed by the test name: write empty conditions emits empty array. + /// Pins that the observation-sequence ctor with no matching entries yields an empty list. [Test] - public void Write_EmptyConditions_EmitsEmptyArray() + public void Ctor_FromObservationSequence_empty_produces_empty_list() { - var conditions = new JsonConditions(); + var conditions = new JsonConditions(Array.Empty()); + Assert.That(conditions.Count, Is.EqualTo(0)); + } + + /// + /// Pins that the observation-sequence ctor emits in the historical + /// level-order (Fault, Warning, Normal, Unavailable), source order + /// within each bucket, so the wire is byte-identical to pre-refactor + /// converter output for the same input. + /// + [Test] + public void Ctor_FromObservationSequence_emits_in_level_order() + { + var observations = new IObservationOutput[] + { + // Deliberately mix input order so the ctor's level-bucketing + // is what determines emission order, not source order. + FakeConditionOutput.Create("n1", ConditionLevel.NORMAL), + FakeConditionOutput.Create("u1", ConditionLevel.UNAVAILABLE), + FakeConditionOutput.Create("f1", ConditionLevel.FAULT), + FakeConditionOutput.Create("w1", ConditionLevel.WARNING), + FakeConditionOutput.Create("f2", ConditionLevel.FAULT), + }; - var json = JsonSerializer.Serialize(conditions, Options()); + var conditions = new JsonConditions(observations); - Assert.That(json, Is.EqualTo("[]")); + Assert.That(conditions.Count, Is.EqualTo(5)); + Assert.That(conditions[0].Level, Is.EqualTo("Fault")); + Assert.That(conditions[0].Fault!.DataItemId, Is.EqualTo("f1")); + Assert.That(conditions[1].Level, Is.EqualTo("Fault")); + Assert.That(conditions[1].Fault!.DataItemId, Is.EqualTo("f2")); + Assert.That(conditions[2].Level, Is.EqualTo("Warning")); + Assert.That(conditions[3].Level, Is.EqualTo("Normal")); + Assert.That(conditions[4].Level, Is.EqualTo("Unavailable")); } - // Case 2 — one Normal entry produces a 1-element array with a Normal wrapper. - /// Pins the behaviour expressed by the test name: write single normal emits one normal wrapper. + /// Pins that null entries in the observation sequence are skipped, not rethrown. [Test] - public void Write_SingleNormal_EmitsOneNormalWrapper() + public void Ctor_FromObservationSequence_skips_null_entries() { - var conditions = new JsonConditions + var observations = new IObservationOutput[] { - Normal = new List { MakeEntry("n1", "TEMPERATURE") }, + null!, + FakeConditionOutput.Create("f1", ConditionLevel.FAULT), + null!, }; - var json = JsonSerializer.Serialize(conditions, Options()); + var conditions = new JsonConditions(observations); - 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)); + Assert.That(conditions.Count, Is.EqualTo(1)); + Assert.That(conditions[0].Level, Is.EqualTo("Fault")); + } - 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")); + // ------------------------------------------------------------------ + // Observations computed accessor. + // ------------------------------------------------------------------ + + /// Pins Observations returns an empty list on an empty container. + [Test] + public void Observations_is_empty_on_empty_container() + { + Assert.That(new JsonConditions().Observations, Is.Empty); } - // Case 3 — Fault + Warning emit in Fault, Warning order per the converter. - /// Pins the behaviour expressed by the test name: write fault then warning emits in declared enumeration order. + /// + /// Pins Observations materializes every non-empty wrapper into the + /// matching strongly-typed condition at the right level, in list + /// insertion order. + /// [Test] - public void Write_FaultThenWarning_EmitsInDeclaredEnumerationOrder() + public void Observations_materializes_in_insertion_order() { var conditions = new JsonConditions { - Fault = new List { MakeEntry("f1", "TEMPERATURE") }, - Warning = new List { MakeEntry("w1", "POSITION") }, + JsonConditionWrapper.OfFault (MakeEntry("f1", "TEMPERATURE")), + JsonConditionWrapper.OfWarning (MakeEntry("w1", "POSITION")), + JsonConditionWrapper.OfNormal (MakeEntry("n1", "AVAILABILITY")), + JsonConditionWrapper.OfUnavailable(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(2)); - - Assert.That(FirstPropertyName(root[0]), Is.EqualTo("Fault")); - Assert.That(FirstPropertyName(root[1]), Is.EqualTo("Warning")); + var observations = conditions.Observations; + + Assert.That(observations.Count, Is.EqualTo(4)); + Assert.That((observations[0] as IConditionObservation)!.Level, Is.EqualTo(ConditionLevel.FAULT)); + Assert.That((observations[1] as IConditionObservation)!.Level, Is.EqualTo(ConditionLevel.WARNING)); + Assert.That((observations[2] as IConditionObservation)!.Level, Is.EqualTo(ConditionLevel.NORMAL)); + Assert.That((observations[3] as IConditionObservation)!.Level, Is.EqualTo(ConditionLevel.UNAVAILABLE)); + Assert.That(observations[0].DataItemId, Is.EqualTo("f1")); + Assert.That(observations[1].DataItemId, Is.EqualTo("w1")); + Assert.That(observations[2].DataItemId, Is.EqualTo("n1")); + Assert.That(observations[3].DataItemId, Is.EqualTo("u1")); } - // Case 4 — all four levels populated emit in Fault, Warning, Normal, Unavailable order. - /// Pins the behaviour expressed by the test name: write all four levels emits in fault warning normal unavailable order. + /// Pins that empty wrappers (all four properties null) are skipped by Observations. [Test] - public void Write_AllFourLevels_EmitsInFaultWarningNormalUnavailableOrder() + public void Observations_skips_empty_wrappers() { 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") }, + new JsonConditionWrapper(), // empty + JsonConditionWrapper.OfFault(MakeEntry("f1")), + null!, // null wrapper + new JsonConditionWrapper(), // empty + JsonConditionWrapper.OfNormal(MakeEntry("n1")), }; - var json = JsonSerializer.Serialize(conditions, Options()); + var observations = conditions.Observations; - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - Assert.That(root.GetArrayLength(), Is.EqualTo(4)); + Assert.That(observations.Count, Is.EqualTo(2)); + Assert.That(observations[0].DataItemId, Is.EqualTo("f1")); + Assert.That(observations[1].DataItemId, Is.EqualTo("n1")); + } - var keys = new List(); - for (var i = 0; i < root.GetArrayLength(); i++) - { - foreach (var prop in root[i].EnumerateObject()) - { - keys.Add(prop.Name); - } - } + // ------------------------------------------------------------------ + // Wire-shape pins — serialization. + // ------------------------------------------------------------------ - Assert.That(keys, Is.EqualTo(new[] { "Fault", "Warning", "Normal", "Unavailable" })); + /// Pins that an empty conditions container serializes to []. + [Test] + public void Serialize_empty_conditions_emits_empty_array() + { + Assert.That(JsonSerializer.Serialize(new JsonConditions()), Is.EqualTo("[]")); } - // Case 5 — multiple entries on one level produce one wrapper each in source order. - /// Pins the behaviour expressed by the test name: write multiple faults emits one wrapper per entry. + /// Pins that a single Normal-wrapped condition serializes to a one-element array. [Test] - public void Write_MultipleFaults_EmitsOneWrapperPerEntry() + public void Serialize_single_normal_emits_one_normal_wrapper() { var conditions = new JsonConditions { - Fault = new List - { - MakeEntry("f1", "TEMPERATURE"), - MakeEntry("f2", "POSITION"), - MakeEntry("f3", "AVAILABILITY"), - }, + JsonConditionWrapper.OfNormal(MakeEntry("n1", "TEMPERATURE")), }; - var json = JsonSerializer.Serialize(conditions, Options()); + var json = JsonSerializer.Serialize(conditions); 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(root.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(root.GetArrayLength(), Is.EqualTo(1)); - Assert.That(ids, Is.EqualTo(new[] { "f1", "f2", "f3" })); + 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 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. - /// Pins the behaviour expressed by the test name: read array shape mixed level interleaving buckets by level. + /// + /// Pins that programmatic list construction preserves insertion + /// order on the wire — the wire array follows list insertion + /// exactly, no re-bucketing. Structural pin (root array + per-index + /// level key + dataItemId) rather than a byte-identical assertion + /// because emits several default-valued + /// fields (timestamp, sequence, instanceId) that are irrelevant to + /// the ordering guarantee under test. + /// [Test] - public void Read_ArrayShape_MixedLevelInterleaving_BucketsByLevel() + public void Serialize_preserves_insertion_order_across_levels() { - 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++) + var conditions = new JsonConditions { - foreach (var prop in rewrittenRoot[i].EnumerateObject()) - { - rewrittenKeys.Add(prop.Name); - rewrittenDataItemIds.Add(prop.Value.GetProperty("dataItemId").GetString()!); - } - } + JsonConditionWrapper.OfNormal(MakeEntry("n1")), + JsonConditionWrapper.OfFault (MakeEntry("f1")), + JsonConditionWrapper.OfNormal(MakeEntry("n2")), + JsonConditionWrapper.OfFault (MakeEntry("f2")), + }; - Assert.That(rewrittenKeys, Is.EqualTo(new[] { "Fault", "Fault", "Normal" })); - Assert.That(rewrittenDataItemIds, Is.EqualTo(new[] { "f1", "f2", "n1" })); - } + var json = JsonSerializer.Serialize(conditions, SparseOptions()); - // Case 6 — array JSON round-trips through Deserialize/Serialize without drift. - /// Pins the behaviour expressed by the test name: round trip array shape is byte identical modulo whitespace. - [Test] - public void RoundTrip_ArrayShape_IsByteIdenticalModuloWhitespace() - { - var original = new JsonConditions + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.That(root.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(root.GetArrayLength(), Is.EqualTo(4)); + + var expected = new[] { - Fault = new List { MakeEntry("f1", "TEMPERATURE") }, - Warning = new List { MakeEntry("w1", "POSITION") }, - Normal = new List { MakeEntry("n1", "AVAILABILITY") }, - Unavailable = new List { MakeEntry("u1", "ROTATION") }, + (Level: "Normal", DataItemId: "n1"), + (Level: "Fault", DataItemId: "f1"), + (Level: "Normal", DataItemId: "n2"), + (Level: "Fault", DataItemId: "f2"), }; - var json = JsonSerializer.Serialize(original, Options()); - var parsed = JsonSerializer.Deserialize(json, Options()); - var json2 = JsonSerializer.Serialize(parsed, Options()); - - Assert.That(json2, Is.EqualTo(json)); + for (var i = 0; i < expected.Length; i++) + { + var wrapper = root[i]; + Assert.That(wrapper.TryGetProperty(expected[i].Level, out var entry), Is.True, + $"index {i} should carry a {expected[i].Level} envelope"); + Assert.That(entry.GetProperty("dataItemId").GetString(), Is.EqualTo(expected[i].DataItemId), + $"index {i} dataItemId should be {expected[i].DataItemId}"); + } } - // Case 7 — legacy MTConnect JSON v1 object-keyed shape parses into the typed POCO. - /// Pins the behaviour expressed by the test name: read legacy object shape populates typed properties. + /// + /// Pins that the observation-taking ctor's level-order emission + /// yields the historical Fault, Warning, Normal, Unavailable + /// sequence on the wire for a mixed-input observation sequence. + /// [Test] - public void Read_LegacyObjectShape_PopulatesTypedProperties() + public void Serialize_from_observation_ctor_emits_in_fault_warning_normal_unavailable_order() { - const string legacy = - "{\"Normal\":[{\"dataItemId\":\"n1\",\"type\":\"TEMPERATURE\"}]," + - "\"Fault\":[{\"dataItemId\":\"f1\",\"type\":\"POSITION\"}]}"; - - var parsed = JsonSerializer.Deserialize(legacy, Options()); + var observations = new IObservationOutput[] + { + FakeConditionOutput.Create("n1", ConditionLevel.NORMAL), + FakeConditionOutput.Create("u1", ConditionLevel.UNAVAILABLE), + FakeConditionOutput.Create("f1", ConditionLevel.FAULT), + FakeConditionOutput.Create("w1", ConditionLevel.WARNING), + }; - Assert.That(parsed, Is.Not.Null); - Assert.That(parsed!.Normal, Is.Not.Null); - Assert.That(parsed.Fault, Is.Not.Null); + var json = JsonSerializer.Serialize(new JsonConditions(observations)); - using var normalEnumerator = parsed.Normal!.GetEnumerator(); - Assert.That(normalEnumerator.MoveNext(), Is.True); - Assert.That(normalEnumerator.Current.DataItemId, Is.EqualTo("n1")); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var keys = new List(); + foreach (var element in root.EnumerateArray()) + { + foreach (var prop in element.EnumerateObject()) + { + keys.Add(prop.Name); + } + } - using var faultEnumerator = parsed.Fault!.GetEnumerator(); - Assert.That(faultEnumerator.MoveNext(), Is.True); - Assert.That(faultEnumerator.Current.DataItemId, Is.EqualTo("f1")); + Assert.That(keys, Is.EqualTo(new[] { "Fault", "Warning", "Normal", "Unavailable" })); } - // Case 8 — null write emits "null" and round-trips back to a null reference. - /// Pins the behaviour expressed by the test name: null write and read round trips to null. - [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); - } + // ------------------------------------------------------------------ + // Wire-shape pins — deserialization. + // ------------------------------------------------------------------ - // Case 9 — invalid root token (number) raises JsonException with a recognisable message. - /// Pins the behaviour expressed by the test name: read invalid root token throws json exception. + /// Pins that [] deserializes to an empty conditions list. [Test] - public void Read_InvalidRootToken_ThrowsJsonException() + public void Deserialize_empty_array_yields_empty_container() { - var ex = Assert.Throws(() => - JsonSerializer.Deserialize("123", Options())); - Assert.That(ex, Is.Not.Null); - Assert.That(ex!.Message, Does.Contain("Unexpected token")); + var conditions = JsonSerializer.Deserialize("[]"); + Assert.That(conditions, Is.Not.Null); + Assert.That(conditions!.Count, Is.EqualTo(0)); } - // Coverage filler — the array-shape read path also handles all four levels - // and rejects unknown level names + malformed wrapper objects. - /// Pins the behaviour expressed by the test name: read array shape populates all four levels. + /// Pins that a mixed-level wire array deserializes with each wrapper's matching level property populated and the other three null. [Test] - public void Read_ArrayShape_PopulatesAllFourLevels() + public void Deserialize_mixed_level_array_populates_each_wrapper_correctly() { - 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); + const string wire = + "[" + + "{\"Fault\":{\"dataItemId\":\"f1\"}}," + + "{\"Normal\":{\"dataItemId\":\"n1\",\"sequence\":42}}," + + "{\"Warning\":{\"dataItemId\":\"w1\",\"type\":\"POSITION\"}}," + + "{\"Unavailable\":{\"dataItemId\":\"u1\"}}" + + "]"; + + var conditions = JsonSerializer.Deserialize(wire); + + Assert.That(conditions!.Count, Is.EqualTo(4)); + + Assert.That(conditions[0].Fault, Is.Not.Null); Assert.That(conditions[0].Fault!.DataItemId, Is.EqualTo("f1")); + Assert.That(conditions[1].Normal, Is.Not.Null); Assert.That(conditions[1].Normal!.DataItemId, Is.EqualTo("n1")); + Assert.That((ulong)conditions[1].Normal!.Sequence, Is.EqualTo(42UL)); + Assert.That(conditions[2].Warning, Is.Not.Null); Assert.That(conditions[2].Warning!.Type, Is.EqualTo("POSITION")); + Assert.That(conditions[3].Unavailable, Is.Not.Null); Assert.That(conditions[3].Unavailable!.DataItemId, Is.EqualTo("u1")); + + // Cross-checks — non-matching properties are null. + Assert.That(conditions[0].Warning, Is.Null); Assert.That(conditions[0].Normal, Is.Null); Assert.That(conditions[0].Unavailable, Is.Null); } - /// Pins the behaviour expressed by the test name: read array shape unknown level throws json exception. + /// + /// Pins byte-identical round-trip through serialize -> deserialize -> + /// serialize on the four-level mixed input, guaranteeing the type + /// shape is symmetric on the wire. + /// [Test] - public void Read_ArrayShape_UnknownLevel_ThrowsJsonException() + public void RoundTrip_array_shape_is_byte_identical() { - 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")); - } + var original = new JsonConditions + { + JsonConditionWrapper.OfFault (MakeEntry("f1", "TEMPERATURE")), + JsonConditionWrapper.OfWarning (MakeEntry("w1", "POSITION")), + JsonConditionWrapper.OfNormal (MakeEntry("n1", "AVAILABILITY")), + JsonConditionWrapper.OfUnavailable(MakeEntry("u1", "ROTATION")), + }; - /// Pins the behaviour expressed by the test name: read array shape non object element throws json exception. - [Test] - public void Read_ArrayShape_NonObjectElement_ThrowsJsonException() - { - const string json = "[42]"; + var json1 = JsonSerializer.Serialize(original); + var parsed = JsonSerializer.Deserialize(json1); + var json2 = JsonSerializer.Serialize(parsed); - var ex = Assert.Throws(() => - JsonSerializer.Deserialize(json, Options())); - Assert.That(ex, Is.Not.Null); - Assert.That(ex!.Message, Does.Contain("expected object wrapper")); + Assert.That(json2, Is.EqualTo(json1)); } - /// Pins the behaviour expressed by the test name: read array shape wrapper without property name throws json exception. + /// Pins that root-level null writes as "null" and reads back as a null reference. [Test] - public void Read_ArrayShape_WrapperWithoutPropertyName_ThrowsJsonException() + public void Null_root_writes_and_reads_as_null() { - 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")); + Assert.That(JsonSerializer.Serialize(null!), Is.EqualTo("null")); + Assert.That(JsonSerializer.Deserialize("null"), Is.Null); } - /// Pins the behaviour expressed by the test name: read array shape wrapper with multiple properties throws json exception. + // ------------------------------------------------------------------ + // Regression pins for the intentional 7.0 behavioral drops. + // These fail loudly if a future refactor accidentally reintroduces + // legacy compat behavior that the type shape rewrite was meant to + // simplify away. + // ------------------------------------------------------------------ + + /// + /// Regression pin for the intentional drop of legacy MTConnect JSON + /// v1 object-keyed READ compat. Pre-7.0 the custom converter + /// accepted {"Fault":[...], "Warning":[...]} on the read + /// path; after the structural rewrite the default + /// deserializer only reads arrays. A future + /// change that reintroduces v1 compat via a new + /// would silently flip this back and + /// break wire-shape invariants; this test fails first. + /// [Test] - public void Read_ArrayShape_WrapperWithMultipleProperties_ThrowsJsonException() + public void Deserialize_legacy_v1_object_keyed_shape_throws_JsonException() { - const string json = "[{\"Fault\":{\"dataItemId\":\"f1\"},\"Warning\":{\"dataItemId\":\"w1\"}}]"; + const string legacyV1 = + "{\"Fault\":[{\"dataItemId\":\"f1\"}]," + + "\"Warning\":[{\"dataItemId\":\"w1\"}]," + + "\"Normal\":[{\"dataItemId\":\"n1\"}]," + + "\"Unavailable\":[{\"dataItemId\":\"u1\"}]}"; - var ex = Assert.Throws(() => - JsonSerializer.Deserialize(json, Options())); - Assert.That(ex, Is.Not.Null); - Assert.That(ex!.Message, Does.Contain("end of JsonConditions wrapper")); + Assert.Throws(() => + JsonSerializer.Deserialize(legacyV1)); } - /// Pins the behaviour expressed by the test name: read array shape null entry throws json exception. + /// + /// Regression pin for the intentional drop of strict single-key + /// wrapper validation. Pre-7.0 the custom converter rejected + /// multi-key wrapper envelopes with a named + /// JsonException; after the structural rewrite the default + /// deserializer tolerates them silently, populating every named + /// property on the wrapper. + /// / resolve by + /// documented Fault > Warning > Normal > Unavailable precedence. + /// A future change that reintroduces strict rejection would flip + /// this test to expect the exception; that is a wire-shape + /// contract change and should be caught here. + /// [Test] - public void Read_ArrayShape_NullEntry_ThrowsJsonException() + public void Deserialize_multi_key_wrapper_populates_all_and_precedence_wins() { - const string json = "[{\"Normal\":null}]"; + const string wire = + "[{\"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("Null Condition entry")); - } + var conditions = JsonSerializer.Deserialize(wire); - /// Pins the behaviour expressed by the test name: read object shape unknown level throws json exception. - [Test] - public void Read_ObjectShape_UnknownLevel_ThrowsJsonException() - { - const string json = "{\"Bogus\":[{\"dataItemId\":\"x1\"}]}"; + Assert.That(conditions, Is.Not.Null); + Assert.That(conditions!.Count, Is.EqualTo(1)); - var ex = Assert.Throws(() => - JsonSerializer.Deserialize(json, Options())); - Assert.That(ex, Is.Not.Null); - Assert.That(ex!.Message, Does.Contain("Unknown Condition level")); - } + var wrapper = conditions[0]; + Assert.That(wrapper.Fault, Is.Not.Null); Assert.That(wrapper.Fault!.DataItemId, Is.EqualTo("f1")); + Assert.That(wrapper.Warning, Is.Not.Null); Assert.That(wrapper.Warning!.DataItemId, Is.EqualTo("w1")); + Assert.That(wrapper.Normal, Is.Null); + Assert.That(wrapper.Unavailable, Is.Null); - /// Pins the behaviour expressed by the test name: read object shape populates all four levels. - [Test] - public void Read_ObjectShape_PopulatesAllFourLevels() - { - const string json = - "{\"Fault\":[{\"dataItemId\":\"f1\"}]," + - "\"Warning\":[{\"dataItemId\":\"w1\"}]," + - "\"Normal\":[{\"dataItemId\":\"n1\"}]," + - "\"Unavailable\":[{\"dataItemId\":\"u1\"}]}"; + // Precedence: Fault > Warning > Normal > Unavailable. + Assert.That(wrapper.Level, Is.EqualTo("Fault")); + Assert.That(wrapper.Value, Is.SameAs(wrapper.Fault)); + Assert.That(wrapper.ToObservation()!.DataItemId, Is.EqualTo("f1")); + } - var parsed = JsonSerializer.Deserialize(json, Options()); + // ------------------------------------------------------------------ + // Minimal fake IObservationOutput for the observation-taking ctor + // coverage — real ObservationOutput requires a full ConditionObservation + // wire-through; this fake exposes only the two fields the ctor reads + // (DataItemId + the Level value bag entry). + // ------------------------------------------------------------------ - 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); + private sealed class FakeConditionOutput : IObservationOutput + { + public static FakeConditionOutput Create(string dataItemId, ConditionLevel level) => + new FakeConditionOutput { DataItemId = dataItemId, Level = level }; + + public ConditionLevel Level { get; init; } + + public string DataItemId { get; init; } = string.Empty; + public string DeviceUuid => string.Empty; + public IDataItem DataItem => null!; + public DataItemCategory Category => DataItemCategory.CONDITION; + public string Type => string.Empty; + public string SubType => string.Empty; + public string Name => string.Empty; + public ulong InstanceId => 0; + public ulong Sequence => 0; + public DateTime Timestamp => DateTime.UnixEpoch; + public DateTimeOffset TimeZoneTimestamp => DateTimeOffset.UnixEpoch; + public string CompositionId => string.Empty; + public DataItemRepresentation Representation => DataItemRepresentation.VALUE; + public Quality Quality => Quality.VALID; + public bool Deprecated => false; + public bool Extended => false; + public ObservationValue[] Values => Array.Empty(); + + public string GetValue(string valueKey) => + valueKey == ValueKeys.Level ? Level.ToString() : null!; } } }