feat(mtconnect): pure data-type inference table + golden test (Task 3)
This commit is contained in:
+192
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The type shape inferred for a single MTConnect <c>DataItem</c>: the scalar
|
||||||
|
/// <see cref="DriverDataType" /> plus the array shape that
|
||||||
|
/// <see cref="DriverAttributeInfo.IsArray" /> / <see cref="DriverAttributeInfo.ArrayDim" />
|
||||||
|
/// expect. Returned by value (a readonly record struct) so the discovery loop and the
|
||||||
|
/// AdminUI editor can call the inference on a hot path without allocating.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DataType">The scalar element type of the observation.</param>
|
||||||
|
/// <param name="IsArray">
|
||||||
|
/// <c>true</c> only for a <c>SAMPLE</c> declared <c>representation="TIME_SERIES"</c>, whose
|
||||||
|
/// observation carries a vector of samples rather than a single value.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ArrayDim">
|
||||||
|
/// The probe-declared array length when <see cref="IsArray" /> is <c>true</c> and the device
|
||||||
|
/// model declared a positive <c>sampleCount</c>; <c>null</c> for a variable-length series
|
||||||
|
/// (many agents omit <c>sampleCount</c>) and always <c>null</c> for a scalar.
|
||||||
|
/// </param>
|
||||||
|
public readonly record struct MTConnectInferredType(DriverDataType DataType, bool IsArray, uint? ArrayDim);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates an MTConnect <c>DataItem</c>'s device-model metadata (category / type / units /
|
||||||
|
/// representation) into the server's <see cref="DriverDataType" />. Pure and deterministic —
|
||||||
|
/// no I/O, no logging, no mutable state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a
|
||||||
|
/// tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c>
|
||||||
|
/// (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the
|
||||||
|
/// universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the
|
||||||
|
/// AdminUI typed tag editor (shows the inferred type and offers an override).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
|
||||||
|
/// defines hundreds of <c>type</c> values with more added each revision. This is therefore a
|
||||||
|
/// <i>rule</i>, not an enumeration, and the result is stored per tag and author-overridable.
|
||||||
|
/// The rule is asymmetric on purpose: a wrong <i>numeric</i> guess produces a value that
|
||||||
|
/// fails to parse and surfaces as Bad quality at runtime, whereas <see cref="DriverDataType.String" />
|
||||||
|
/// always round-trips and the author can retype it. So every default leans to
|
||||||
|
/// <c>String</c> except where the standard itself guarantees a number.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Defaulting rule, per category</b> (design §3.3):
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <c>SAMPLE</c> ⇒ <see cref="DriverDataType.Float64" />, <i>including for an
|
||||||
|
/// unrecognised or missing <c>type</c></i>. The standard defines the whole SAMPLE
|
||||||
|
/// category as a continuously-varying measured quantity, so the category alone is
|
||||||
|
/// the guarantee — a missing <c>units</c> attribute is a gap in the device model,
|
||||||
|
/// not evidence that the observation is text. <c>Float64</c> rather than an integer
|
||||||
|
/// type because a double parse accepts both <c>3</c> and <c>3.5</c>.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <c>EVENT</c> ⇒ <see cref="DriverDataType.String" /> by default, with a small
|
||||||
|
/// exception list (<see cref="NumericEventTypes" />) of standard types that are
|
||||||
|
/// defined as integers. The overwhelming majority of EVENT types are controlled
|
||||||
|
/// vocabulary (<c>Execution</c>, <c>ControllerMode</c>, <c>Availability</c>) or free
|
||||||
|
/// text (<c>Program</c>, <c>Block</c>, <c>Message</c>), and the exception list is
|
||||||
|
/// deliberately short — each entry is a coercion risk, while every omission is
|
||||||
|
/// merely a string the author can retype.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <c>CONDITION</c> ⇒ <see cref="DriverDataType.String" /> always. The observation
|
||||||
|
/// is a state word (<c>Normal</c> / <c>Warning</c> / <c>Fault</c> /
|
||||||
|
/// <c>Unavailable</c>), never a number, regardless of the item's <c>type</c>.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// An <b>unknown, blank, or missing category</b> ⇒ <see cref="DriverDataType.String" />.
|
||||||
|
/// Probe XML in the wild is inconsistent; with no category we have no evidence the
|
||||||
|
/// observation is numeric, so we pick the type that cannot fail to parse.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// Representation overrides the category where the two disagree about shape:
|
||||||
|
/// <c>DATA_SET</c> / <c>TABLE</c> observations are key-value maps, so they demote to
|
||||||
|
/// <see cref="DriverDataType.String" /> even on a <c>SAMPLE</c>; <c>TIME_SERIES</c> is
|
||||||
|
/// defined only for <c>SAMPLE</c> and is ignored (not treated as an array) elsewhere;
|
||||||
|
/// <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Matching is case-insensitive and whitespace-trimmed on every input, because agents vary
|
||||||
|
/// in how faithfully they echo the standard's spellings.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class MTConnectDataTypeInference
|
||||||
|
{
|
||||||
|
private const string CategorySample = "SAMPLE";
|
||||||
|
private const string CategoryEvent = "EVENT";
|
||||||
|
|
||||||
|
private const string RepresentationTimeSeries = "TIME_SERIES";
|
||||||
|
private const string RepresentationDataSet = "DATA_SET";
|
||||||
|
private const string RepresentationTable = "TABLE";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>EVENT</c> types the standard defines as integers — the exception list to the
|
||||||
|
/// "EVENT is text" default. Kept deliberately short: adding a type here is a claim that
|
||||||
|
/// every agent reports it as a parseable integer, and being wrong costs Bad quality.
|
||||||
|
/// <c>Line</c> is the deprecated spelling that <c>LineNumber</c> superseded in MTConnect 1.4;
|
||||||
|
/// both are mapped so a current-version agent is not silently mistyped.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly FrozenSet<string> NumericEventTypes =
|
||||||
|
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model
|
||||||
|
/// metadata. See the type-level remarks for the full rule and its rationale.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="category">
|
||||||
|
/// The <c>DataItem</c> category — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.
|
||||||
|
/// Case-insensitive; an unknown, blank, or <c>null</c> value yields
|
||||||
|
/// <see cref="DriverDataType.String" />.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="type">
|
||||||
|
/// The <c>DataItem</c> type (e.g. <c>Position</c>, <c>PartCount</c>, <c>Execution</c>).
|
||||||
|
/// Case-insensitive; an unrecognised or <c>null</c> value falls back to the category default.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="units">
|
||||||
|
/// The declared engineering units, if any. Accepted for signature stability and because every
|
||||||
|
/// call site already holds it, but <b>not load-bearing in v1</b>: <c>SAMPLE</c> is numeric by
|
||||||
|
/// definition of the category, so units add no discriminating power there, and treating a
|
||||||
|
/// units-bearing <c>EVENT</c> as numeric would be exactly the risky coercion this rule avoids.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="representation">
|
||||||
|
/// The <c>DataItem</c> representation — <c>VALUE</c> (default), <c>TIME_SERIES</c>,
|
||||||
|
/// <c>DATA_SET</c>, <c>TABLE</c>, or <c>DISCRETE</c>. Case-insensitive.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="sampleCount">
|
||||||
|
/// The probe-declared <c>sampleCount</c> for a <c>TIME_SERIES</c> item. Flows into
|
||||||
|
/// <see cref="MTConnectInferredType.ArrayDim" /> when positive; <c>null</c> or a non-positive
|
||||||
|
/// value yields a variable-length array (<c>ArrayDim = null</c>). Ignored for scalars.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The inferred scalar type plus array shape.</returns>
|
||||||
|
public static MTConnectInferredType Infer(
|
||||||
|
string? category,
|
||||||
|
string? type = null,
|
||||||
|
string? units = null,
|
||||||
|
string? representation = null,
|
||||||
|
int? sampleCount = null)
|
||||||
|
{
|
||||||
|
_ = units; // See the parameter doc: intentionally not load-bearing in v1.
|
||||||
|
|
||||||
|
var trimmedCategory = category.AsSpan().Trim();
|
||||||
|
var trimmedRepresentation = representation.AsSpan().Trim();
|
||||||
|
|
||||||
|
// A key-value observation is never a number, whatever the category claims. Checked first so
|
||||||
|
// a DATA_SET SAMPLE cannot slip through as Float64 and go Bad on every parse.
|
||||||
|
if (Matches(trimmedRepresentation, RepresentationDataSet) || Matches(trimmedRepresentation, RepresentationTable))
|
||||||
|
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
|
||||||
|
|
||||||
|
if (Matches(trimmedCategory, CategorySample))
|
||||||
|
{
|
||||||
|
// TIME_SERIES is defined for SAMPLE only; the vector is a vector of the same scalar type.
|
||||||
|
if (Matches(trimmedRepresentation, RepresentationTimeSeries))
|
||||||
|
return new MTConnectInferredType(
|
||||||
|
DriverDataType.Float64,
|
||||||
|
IsArray: true,
|
||||||
|
ArrayDim: sampleCount is > 0 ? (uint)sampleCount.Value : null);
|
||||||
|
|
||||||
|
return new MTConnectInferredType(DriverDataType.Float64, IsArray: false, ArrayDim: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Matches(trimmedCategory, CategoryEvent))
|
||||||
|
{
|
||||||
|
var trimmedType = type?.Trim();
|
||||||
|
var dataType = trimmedType is { Length: > 0 } && NumericEventTypes.Contains(trimmedType)
|
||||||
|
? DriverDataType.Int64
|
||||||
|
: DriverDataType.String;
|
||||||
|
|
||||||
|
return new MTConnectInferredType(dataType, IsArray: false, ArrayDim: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONDITION (a state word) and every unknown / blank / missing category.
|
||||||
|
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Matches(ReadOnlySpan<char> value, string expected)
|
||||||
|
=> value.Equals(expected, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
+250
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user