Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
# paid exactly once across the whole workflow (in job 2 below).
# ------------------------------------------------------------------
build-and-test:
name: build-and-test-${{ matrix.os }}
name: build-and-test
# Skip drafts: run only on push-to-master + ready (non-draft) PRs.
# The pull_request `types` list above includes `ready_for_review`
# so CI fires the moment a draft is flipped to ready.
Expand Down Expand Up @@ -323,7 +323,7 @@ jobs:
# categories run exactly once (in job 1) across the workflow.
# ------------------------------------------------------------------
route-check-e2e:
name: route-check-e2e-shard${{ matrix.shard }}of${{ matrix.shardTotal }}
name: route-check-e2e
if: github.event_name == 'push' || github.event.pull_request.draft == false
needs: docs-prepare
runs-on: ubuntu-latest
Expand Down
46 changes: 46 additions & 0 deletions libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2152,6 +2152,17 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput,
var dataItem = GetDataItem(deviceUuid, input.DataItemKey);
if (dataItem != null)
{
// Coerce a null, empty, or whitespace-only Result to UNAVAILABLE before the
// observation reaches the buffer. The MTConnect Part 1 Observation Information
// Model mandates "UNAVAILABLE" as the sole valid representation of a missing
// value; an empty string is never a Valid Data Value. The coerce runs above
// validation so Strict no longer silently drops empty-Result observations and
// every other validation level publishes the spec-compliant sentinel.
if (dataItem.Category != DataItemCategory.CONDITION && IsEmptyResult(input))
{
CoerceEmptyResultToUnavailable(input);
}

// Add required properties
switch (dataItem.Representation)
{
Expand Down Expand Up @@ -2277,6 +2288,41 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput,
}


/// <summary>
/// Returns true when the observation's Result value is null, the empty string, or whitespace-only.
/// </summary>
/// <remarks>
/// An empty Result is, by definition, not a Valid Data Value under any typed DataItem schema; the
/// MTConnect Part 1 Observation Information Model mandates "UNAVAILABLE" as the sole valid
/// representation of a missing value. This predicate identifies the inputs the SDK coerces.
/// </remarks>
private static bool IsEmptyResult(IObservationInput input)
{
var result = input.GetValue(ValueKeys.Result);
if (result == null) return true;
return string.IsNullOrWhiteSpace(result);
}

/// <summary>
/// Rewrites the observation's Result value to <see cref="Observation.Unavailable"/> and flags the input as unavailable.
/// </summary>
/// <remarks>
/// Removes any prior Result entry (so the Values collection does not carry a duplicate ValueKey),
/// adds the UNAVAILABLE sentinel, and sets <see cref="IObservationInput.IsUnavailable"/> so downstream
/// consumers that branch on the flag observe the coerced state. Spec authority: MTConnect Part 1
/// Observation Information Model - Representation - Observation Values.
/// </remarks>
private static void CoerceEmptyResultToUnavailable(IObservationInput input)
{
var preserved = (input.Values ?? Enumerable.Empty<ObservationValue>())
.Where(v => v.Key != ValueKeys.Result)
.ToList();
input.Values = preserved;
input.AddValue(ValueKeys.Result, Observation.Unavailable);
input.IsUnavailable = true;
}


/// <summary>
/// Add new Observations for DataItems to the Agent
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
// TrakHound Inc. licenses this file to you under the MIT license.

using System;
using System.Linq;
using MTConnect.Agents;
using MTConnect.Configurations;
using MTConnect.Devices;
using MTConnect.Devices.DataItems;
using MTConnect.Observations;
using NUnit.Framework;

namespace MTConnect.Tests.Common.Agents
{
/// <summary>
/// Regression pin for the empty-Result wire-level spec violation in
/// <see cref="MTConnectAgent.AddObservation(string, MTConnect.Input.IObservationInput, bool?, bool?, bool?, bool)"/>:
/// when the inbound observation carries a null, empty, or whitespace-only
/// Result value, the agent currently publishes that value verbatim on the
/// wire. The MTConnect Part 1 Observation Information Model mandates
/// "UNAVAILABLE" as the sole valid representation of a missing value, so
/// the SDK must coerce null / empty / whitespace to
/// <see cref="Observation.Unavailable"/> before the observation reaches
/// the buffer.
///
/// Spec: MTConnect Standard, Part 1 - Devices Information Model,
/// Observation Information Model - Representation - Observation Values
/// ("If an Agent cannot determine a Valid Data Value for a DataItem, the
/// value returned for the Result for the Data Entity MUST be reported as
/// UNAVAILABLE.").
///
/// This fixture pins the coerce on the canonical
/// <c>AddObservation(string, IObservationInput, ...)</c> overload that
/// every other AddObservation overload routes through. The convenience
/// overload <c>AddObservation(string deviceKey, string dataItemKey, object value, DateTime timestamp)</c>
/// at MTConnectAgent.cs:2007 is used as the exercise vector because it
/// builds a fresh ObservationInput and routes through the canonical path
/// at MTConnectAgent.cs:2123.
/// </summary>
[TestFixture]
[Category("AddObservationEmptyResultCoerce")]
public class AddObservationEmptyResultCoerceTests
{
private const string DeviceKey = "U-COERCE";
private const string DataItemKey = "availability";

private static readonly InputValidationLevel[] _nonStrictLevels =
{
InputValidationLevel.Ignore,
InputValidationLevel.Warning,
InputValidationLevel.Remove,
};

private static readonly object?[] _nullEmptyWhitespaceValues =
{
new object?[] { null },
new object?[] { string.Empty },
new object?[] { " " },
new object?[] { "\t" },
new object?[] { "\n" },
};

/// <summary>Pins the positive contract: under every non-Strict input-validation level, an empty-string Result is coerced to <see cref="Observation.Unavailable"/> on the wire.</summary>
/// <param name="level">The input-validation level the agent operates under.</param>
[Test]
[TestCaseSource(nameof(_nonStrictLevels))]
public void AddObservation_EmptyStringResult_Coerced_To_Unavailable_Under_NonStrict_Levels(InputValidationLevel level)
{
using var agent = NewAgentWithAvailabilityDataItem(level);

var added = agent.AddObservation(DeviceKey, DataItemKey, (object)string.Empty, DateTime.UtcNow);

Assert.That(added, Is.True, "empty-Result observation must reach the buffer post-coerce");
Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable),
"Part 1 mandates UNAVAILABLE for any Result that is null, empty, or whitespace");
}

/// <summary>Pins the positive contract across the null / empty / whitespace family: every such Result is coerced to <see cref="Observation.Unavailable"/>.</summary>
/// <param name="badValue">The non-Valid-Data-Value Result the caller forwards in.</param>
[Test]
[TestCaseSource(nameof(_nullEmptyWhitespaceValues))]
public void AddObservation_NullEmptyOrWhitespaceResult_Coerced_To_Unavailable(object? badValue)
{
using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Warning);

var added = agent.AddObservation(DeviceKey, DataItemKey, badValue!, DateTime.UtcNow);

Assert.That(added, Is.True);
Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable));
}

/// <summary>Pins the secondary defect's positive contract: under <see cref="InputValidationLevel.Strict"/>, an empty-Result observation is coerced and lands in the buffer rather than being silently dropped.</summary>
[Test]
public void AddObservation_EmptyResult_Under_Strict_Coerced_And_Lands()
{
using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Strict);

var added = agent.AddObservation(DeviceKey, DataItemKey, (object)string.Empty, DateTime.UtcNow);

Assert.That(added, Is.True, "Strict must coerce to UNAVAILABLE — never silently drop an empty Result");
Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable));
}

/// <summary>Pins the negative contract: a concrete non-empty Result is preserved verbatim — the coerce only fires on the null / empty / whitespace family.</summary>
[Test]
public void AddObservation_ConcreteResult_Is_Preserved_Verbatim()
{
using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Warning);

var added = agent.AddObservation(DeviceKey, DataItemKey, (object)"AVAILABLE", DateTime.UtcNow);

Assert.That(added, Is.True);
Assert.That(CurrentResult(agent), Is.EqualTo("AVAILABLE"),
"the coerce must not substitute a sentinel for a Valid Data Value");
}

private static MTConnectAgentBroker NewAgentWithAvailabilityDataItem(InputValidationLevel level)
{
var config = new AgentConfiguration { InputValidationLevel = level };
var agent = new MTConnectAgentBroker(config);
agent.Start();

var device = new Device
{
Id = "d-coerce",
Name = "d-coerce",
Uuid = DeviceKey,
};
device.AddDataItem(new AvailabilityDataItem(device.Id));

var added = agent.AddDevice(device);
Assert.That(added, Is.Not.Null, "AddDevice must succeed for test pre-condition");
return agent;
}

private static object? CurrentResult(IMTConnectAgentBroker agent)
{
var current = agent.GetCurrentObservations(DeviceKey, DataItemKey).SingleOrDefault();
return current?.GetValue(ValueKeys.Result);
}
}
}
Loading
Loading