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
+ /// (FAULT → WARNING → NORMAL → UNAVAILABLE,
+ /// 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
+ /// (FAULT → WARNING → NORMAL → UNAVAILABLE,
+ /// 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!;
}
}
}