feat(mtconnect): pure data-type inference table + golden test (Task 3)

This commit is contained in:
Joseph Doherty
2026-07-24 13:53:13 -04:00
parent 992ffe5620
commit c0729ae5c0
2 changed files with 442 additions and 0 deletions
@@ -0,0 +1,250 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Golden table for <see cref="MTConnectDataTypeInference.Infer" /> (design §3.3).
/// Three call sites must agree byte-for-byte on this mapping — <c>ITagDiscovery.DiscoverAsync</c>,
/// the universal browser's commit path, and the AdminUI typed tag editor — so the table is
/// pinned here rather than re-derived at each seam.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDataTypeInferenceTests
{
// ---------------------------------------------------------------------
// The design §3.3 golden table.
// ---------------------------------------------------------------------
[Theory]
// SAMPLE numeric with units → Float64.
[InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
[InlineData("SAMPLE", "SpindleSpeed", "REVOLUTION/MINUTE", "VALUE", DriverDataType.Float64)]
// EVENT numeric type → Int64.
[InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
[InlineData("EVENT", "Line", null, null, DriverDataType.Int64)]
// EVENT controlled vocabulary → String.
[InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
[InlineData("EVENT", "ControllerMode", null, null, DriverDataType.String)]
[InlineData("EVENT", "Availability", null, null, DriverDataType.String)]
// EVENT free text → String.
[InlineData("EVENT", "Program", null, null, DriverDataType.String)]
[InlineData("EVENT", "Block", null, null, DriverDataType.String)]
[InlineData("EVENT", "Message", null, null, DriverDataType.String)]
// CONDITION → String (the state word).
[InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
[InlineData("CONDITION", "SystemCondition", null, null, DriverDataType.String)]
public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
[Fact]
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBe(8u);
}
// ---------------------------------------------------------------------
// Array shape: only a SAMPLE/TIME_SERIES is an array, and ArrayDim is
// only populated when the probe declared a usable sampleCount.
// ---------------------------------------------------------------------
[Fact]
public void TimeSeries_without_a_declared_sample_count_is_a_variable_length_array()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES");
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void TimeSeries_with_a_non_positive_sample_count_reports_no_dimension(int declared)
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", declared);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData("SAMPLE", "Position", "VALUE")]
[InlineData("SAMPLE", "Position", null)]
[InlineData("EVENT", "PartCount", "VALUE")]
[InlineData("CONDITION", "Temperature", null)]
public void A_scalar_item_is_never_an_array(string cat, string type, string? rep)
{
var r = MTConnectDataTypeInference.Infer(cat, type, null, rep);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
}
[Fact]
public void A_declared_sample_count_is_ignored_when_the_item_is_not_a_time_series()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "VALUE", sampleCount: 8);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData("EVENT")]
[InlineData("CONDITION")]
public void TIME_SERIES_outside_the_SAMPLE_category_is_not_treated_as_an_array(string cat)
{
// TIME_SERIES is defined only for SAMPLE. Honouring it elsewhere would hand the
// address space an array node whose observations are scalars.
var r = MTConnectDataTypeInference.Infer(cat, "Anything", null, "TIME_SERIES", sampleCount: 8);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
r.DataType.ShouldBe(DriverDataType.String);
}
// ---------------------------------------------------------------------
// The defaulting rule for unrecognised `type` values — the part of the
// table that is a *rule*, not an enumeration. MTConnect defines hundreds
// of types; the fallback per category is what actually ships.
// ---------------------------------------------------------------------
[Theory]
[InlineData("Xacceleration")]
[InlineData("SomeVendorExtension")]
[InlineData(null)]
[InlineData("")]
public void An_unrecognised_SAMPLE_type_still_defaults_to_Float64(string? type)
=> MTConnectDataTypeInference.Infer("SAMPLE", type, null, null).DataType.ShouldBe(DriverDataType.Float64);
[Fact]
public void A_SAMPLE_without_units_is_still_numeric()
// SAMPLE is numeric by definition in the standard; a missing `units` attribute is a
// gap in the device model, not evidence that the observation is text.
=> MTConnectDataTypeInference.Infer("SAMPLE", "Position", null, null).DataType
.ShouldBe(DriverDataType.Float64);
[Theory]
[InlineData("ToolNumber")]
[InlineData("PalletId")]
[InlineData("LineLabel")]
[InlineData("SomeVendorExtension")]
[InlineData(null)]
[InlineData("")]
public void An_unrecognised_EVENT_type_defaults_to_String(string? type)
// Fail-safe direction: a wrong numeric coercion produces a Bad-quality value at runtime,
// whereas String always round-trips and the author can override.
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);
[Theory]
[InlineData("LineNumber")]
public void The_modern_spelling_of_a_known_numeric_EVENT_is_also_numeric(string type)
// `Line` was superseded by `LineNumber`; mapping only the deprecated spelling would
// silently mistype the same data item on any current-version agent.
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
[Theory]
[InlineData("Anything")]
[InlineData(null)]
public void Every_CONDITION_is_a_String_regardless_of_type(string? type)
=> MTConnectDataTypeInference.Infer("CONDITION", type, "CELSIUS", null).DataType
.ShouldBe(DriverDataType.String);
// ---------------------------------------------------------------------
// Unknown / missing category — probe XML in the wild is inconsistent.
// ---------------------------------------------------------------------
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("ASSET")]
[InlineData("nonsense")]
public void An_unknown_or_missing_category_falls_back_to_String(string? cat)
// We cannot know the observation is numeric, so we choose the type that always
// round-trips rather than one that can fail to parse.
=> MTConnectDataTypeInference.Infer(cat, "Position", "MILLIMETER", null).DataType
.ShouldBe(DriverDataType.String);
// ---------------------------------------------------------------------
// Case / whitespace tolerance.
// ---------------------------------------------------------------------
[Theory]
[InlineData("sample", DriverDataType.Float64)]
[InlineData("Sample", DriverDataType.Float64)]
[InlineData(" SAMPLE ", DriverDataType.Float64)]
[InlineData("condition", DriverDataType.String)]
public void Category_matching_is_case_and_whitespace_insensitive(string cat, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, "Position", "MM", null).DataType.ShouldBe(expected);
[Theory]
[InlineData("partcount")]
[InlineData("PARTCOUNT")]
[InlineData(" PartCount ")]
public void Numeric_EVENT_type_matching_is_case_and_whitespace_insensitive(string type)
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
[Theory]
[InlineData("time_series")]
[InlineData("Time_Series")]
[InlineData(" TIME_SERIES ")]
public void Representation_matching_is_case_and_whitespace_insensitive(string rep)
=> MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", rep, 4).IsArray.ShouldBeTrue();
// ---------------------------------------------------------------------
// Non-scalar representations. A DATA_SET / TABLE observation is a
// key-value map, not a number — typing it Float64 guarantees Bad quality.
// ---------------------------------------------------------------------
[Theory]
[InlineData("DATA_SET")]
[InlineData("TABLE")]
[InlineData("data_set")]
public void A_key_value_representation_is_a_String_even_on_a_SAMPLE(string rep)
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", rep);
r.DataType.ShouldBe(DriverDataType.String);
r.IsArray.ShouldBeFalse();
}
[Fact]
public void A_key_value_representation_also_demotes_a_numeric_EVENT()
{
var r = MTConnectDataTypeInference.Infer("EVENT", "PartCount", null, "DATA_SET");
r.DataType.ShouldBe(DriverDataType.String);
r.IsArray.ShouldBeFalse();
}
[Fact]
public void DISCRETE_is_a_scalar_representation_and_does_not_change_the_type()
{
// DISCRETE changes *when* the agent reports, not what the value is.
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", "DISCRETE");
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeFalse();
}
// ---------------------------------------------------------------------
// Purity — the three call sites rely on identical results for identical
// inputs with no shared state between calls.
// ---------------------------------------------------------------------
[Fact]
public void Infer_is_deterministic()
{
var first = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
var second = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
second.ShouldBe(first);
}
}