feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8)
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Frozen;
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The driver's live <c>dataItemId → <see cref="DataValueSnapshot"/></c> map: the single place
|
||||
/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
|
||||
/// timestamp. Written by the <c>/current</c> baseline read and by the <c>/sample</c> long-poll
|
||||
/// pump; read by <c>IReadable.ReadAsync</c> and the subscription fan-out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Quality mapping (design §3.3).</b> MTConnect has exactly one way to say "I have no
|
||||
/// value": the literal token <c>UNAVAILABLE</c>. It arrives as a Sample/Event's text, and —
|
||||
/// after <see cref="MTConnectStreamsParser"/> normalizes the <c><Unavailable/></c>
|
||||
/// element name — as a CONDITION's value on that same exact spelling. It maps to
|
||||
/// <c>BadNoCommunication</c> with a <c>null</c> value: the Agent is reachable, the machine's
|
||||
/// data item is not. The match is <b>ordinal and case-sensitive</b> against the one spelling
|
||||
/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
|
||||
/// EVENT whose value happens to read "Unavailable".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> This type sits under <c>IReadable</c> and must never throw across
|
||||
/// that boundary on account of observation content. Every unusable observation degrades to a
|
||||
/// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
|
||||
/// always see <i>when</i> the driver last heard about the tag even when it cannot show
|
||||
/// <i>what</i>. The only exceptions raised are <see cref="ArgumentNullException"/> for a null
|
||||
/// argument — a programming error, not data.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Status codes</b> are the canonical <c>Opc.Ua.StatusCodes</c> numeric values, declared
|
||||
/// once each below. Note that <see cref="BadTypeMismatch"/> is <c>0x80740000</c>: the
|
||||
/// <c>0x80730000</c> that four sibling drivers declare under that name is really
|
||||
/// <c>BadWriteNotSupported</c>, which would be actively misleading on a read path and
|
||||
/// renders as bare "Bad" in the driver CLI's <c>SnapshotFormatter</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Thread safety.</b> Backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> and
|
||||
/// guaranteed <b>per-key atomic</b> — nothing more, deliberately. <see cref="Apply"/> writes
|
||||
/// each dataItemId exactly once per call with a single whole-snapshot assignment, so a
|
||||
/// reader can never observe a torn snapshot (a Good code beside a stale value). It is
|
||||
/// explicitly <b>not</b> a whole-batch atomic swap: OPC UA reads are per-tag and the driver
|
||||
/// offers no cross-tag consistency contract, <c>/sample</c> chunks are deltas whose
|
||||
/// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
|
||||
/// would serialize the pump against every read for no semantic gain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>KNOWN LIMITATION — <c>DATA_SET</c> / <c>TABLE</c> observations.</b> Their real content
|
||||
/// is <c><Entry key=…></c> child elements, but the parser reads an observation's value
|
||||
/// with <c>XElement.Value</c>, which concatenates all descendant text. Such an observation
|
||||
/// therefore reaches this index as a run-together string with the keys discarded, and —
|
||||
/// because the type inference correctly demotes <c>DATA_SET</c>/<c>TABLE</c> to
|
||||
/// <see cref="DriverDataType.String"/> — it coerces "successfully" into a <b>Good-coded
|
||||
/// snapshot carrying noise</b>. This index <b>cannot</b> detect the shape: neither
|
||||
/// <see cref="MTConnectObservation"/> nor <see cref="MTConnectTagDefinition"/> carries the
|
||||
/// DataItem's <c>representation</c>, and no value-shape heuristic can separate a
|
||||
/// concatenated entry list from a legitimate space-bearing EVENT (a <c>Message</c> reads
|
||||
/// "Coolant level low"). The discriminating signal — the observation element having child
|
||||
/// elements — exists only in <see cref="MTConnectStreamsParser"/>, so the fix belongs there
|
||||
/// (flag structured observations at parse time; this index then codes them
|
||||
/// <c>BadNotSupported</c> alongside the TIME_SERIES deferral below).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class MTConnectObservationIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// The one token that means "the Agent has no value for this data item". Matched ordinally:
|
||||
/// the parser already normalizes a CONDITION's <c><Unavailable/></c> element name onto
|
||||
/// this exact spelling, so every category lands on one comparison.
|
||||
/// </summary>
|
||||
private const string UnavailableSentinel = "UNAVAILABLE";
|
||||
|
||||
private const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>The Agent reported <c>UNAVAILABLE</c>, or supplied no text at all.</summary>
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>The tag is authored but the Agent has not yet reported it.</summary>
|
||||
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>The dataItemId is neither authored nor ever observed.</summary>
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
|
||||
/// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
|
||||
private const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
|
||||
/// several states reported for one dataItemId <i>within a single document</i> — see
|
||||
/// <see cref="Reconcile"/>. An active <c>Fault</c> outranks <c>UNAVAILABLE</c> because a
|
||||
/// fault is information and an absent value is not. Case-insensitive so a vendor's casing
|
||||
/// of a state element still ranks; the parser itself emits the canonical spellings.
|
||||
/// </summary>
|
||||
private static readonly FrozenDictionary<string, int> ConditionSeverity =
|
||||
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[UnavailableSentinel] = 0,
|
||||
["Normal"] = 1,
|
||||
["Warning"] = 2,
|
||||
["Fault"] = 3,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _snapshots = new(StringComparer.Ordinal);
|
||||
private readonly FrozenDictionary<string, MTConnectTagDefinition> _tags;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an index over the driver instance's authored tags.
|
||||
/// </summary>
|
||||
/// <param name="tags">
|
||||
/// The authored tags, keyed by <see cref="MTConnectTagDefinition.FullName"/> (the DataItem
|
||||
/// <c>id</c>) — normally <see cref="MTConnectDriverOptions.Tags"/>. <c>null</c> or empty is
|
||||
/// legal and means "no authored types": every observation is then indexed as its raw text
|
||||
/// under <see cref="DriverDataType.String"/>.
|
||||
/// <para>
|
||||
/// Nothing enforces <c>FullName</c> uniqueness at the options layer, so a duplicate is
|
||||
/// operator-authorable. <b>The last definition wins</b> rather than throwing: refusing to
|
||||
/// construct would turn an authoring slip into a driver that will not start, whereas
|
||||
/// last-wins is deterministic and costs only that one tag's coercion type.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="timeProvider">Clock behind <c>ServerTimestampUtc</c>; defaults to <see cref="TimeProvider.System"/>.</param>
|
||||
public MTConnectObservationIndex(
|
||||
IEnumerable<MTConnectTagDefinition>? tags = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
|
||||
foreach (var tag in tags ?? [])
|
||||
{
|
||||
if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
|
||||
|
||||
map[tag.FullName] = tag;
|
||||
}
|
||||
|
||||
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
|
||||
public int Count => _snapshots.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the index. Observations
|
||||
/// with no authored tag are indexed too — the Agent streams the whole device, so they are
|
||||
/// normal, and dropping them would make a streaming-but-not-yet-authored data item
|
||||
/// indistinguishable from one that does not exist.
|
||||
/// </summary>
|
||||
/// <param name="result">The parsed streams document. An observation-free result is a legal
|
||||
/// keep-alive and leaves the index untouched.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="result"/> is <c>null</c>.</exception>
|
||||
public void Apply(MTConnectStreamsResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
if (result.Observations is not { Count: > 0 }) return;
|
||||
|
||||
// Duplicates are reconciled on the RAW observations first, so each dataItemId is written
|
||||
// exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
|
||||
// — writing every observation and merging snapshots in place — would need a read-modify-write
|
||||
// per key and could expose a half-merged state to a concurrent reader.
|
||||
var resolved = new Dictionary<string, MTConnectObservation>(StringComparer.Ordinal);
|
||||
foreach (var observation in result.Observations)
|
||||
{
|
||||
if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
|
||||
|
||||
resolved[observation.DataItemId] =
|
||||
resolved.TryGetValue(observation.DataItemId, out var incumbent)
|
||||
? Reconcile(incumbent, observation)
|
||||
: observation;
|
||||
}
|
||||
|
||||
foreach (var (dataItemId, observation) in resolved)
|
||||
{
|
||||
_snapshots[dataItemId] = BuildSnapshot(observation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current snapshot for a DataItem. Never <c>null</c> and never throws: an
|
||||
/// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
|
||||
/// </summary>
|
||||
/// <param name="dataItemId">The DataItem <c>id</c> the tag binds.</param>
|
||||
/// <returns>
|
||||
/// The indexed snapshot; else <c>BadWaitingForInitialData</c> when the id is authored but
|
||||
/// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
|
||||
/// state); else <c>BadNodeIdUnknown</c>. The two are kept distinct because collapsing them
|
||||
/// would tell an operator their correctly-authored tag does not exist.
|
||||
/// </returns>
|
||||
public DataValueSnapshot Get(string dataItemId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(dataItemId) &&
|
||||
_snapshots.TryGetValue(dataItemId, out var snapshot))
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
|
||||
? BadWaitingForInitialData
|
||||
: BadNodeIdUnknown;
|
||||
|
||||
return new DataValueSnapshot(null, status, null, Now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
|
||||
/// <c>instanceId</c> changes, every value the index holds describes a device model that no
|
||||
/// longer exists, and serving it would be worse than serving nothing.
|
||||
/// </summary>
|
||||
public void Clear() => _snapshots.Clear();
|
||||
|
||||
private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the winner when one dataItemId appears more than once in a single document. A
|
||||
/// <c><Condition></c> container legitimately carries several <b>simultaneously active</b>
|
||||
/// states (a Fault and a Warning at once), so for a pair of recognized condition state words
|
||||
/// the <b>most severe</b> wins — plain last-wins would under-report an active Fault as a
|
||||
/// Warning purely because of element order. Anything else is last-wins, matching the Agent's
|
||||
/// ascending-sequence ordering.
|
||||
/// <para>
|
||||
/// Deliberately scoped to one document: a later <see cref="Apply"/> is the Agent's new
|
||||
/// statement of the state, so a cleared fault must fall back to Normal. A worst-of that
|
||||
/// spanned Applies would latch every fault forever.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
|
||||
{
|
||||
if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
|
||||
ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
|
||||
{
|
||||
return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
|
||||
}
|
||||
|
||||
return challenger;
|
||||
}
|
||||
|
||||
private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
|
||||
{
|
||||
var serverTimestamp = Now;
|
||||
var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
|
||||
|
||||
// No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
|
||||
// the only way to report absence, so an empty element is not an empty string value.
|
||||
if (string.IsNullOrWhiteSpace(observation.Value) ||
|
||||
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
|
||||
{
|
||||
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
_tags.TryGetValue(observation.DataItemId, out var tag);
|
||||
|
||||
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
|
||||
// arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
|
||||
// type mismatch, and emphatically not the first element parsed out and served as Good.
|
||||
// Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
|
||||
if (tag is { IsArray: true })
|
||||
{
|
||||
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
// An unauthored observation has no declared target type; String is the only coercion that
|
||||
// cannot fail, and it carries the Agent's text with no interpretation applied.
|
||||
var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);
|
||||
|
||||
return status == Good
|
||||
? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
|
||||
: new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the Agent's text to the tag's authored type. Every parse is
|
||||
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
|
||||
/// host running a comma-decimal culture would otherwise read <c>123.4567</c> as
|
||||
/// <c>1234567</c> — a silently wrong value under Good quality, the worst possible outcome.
|
||||
/// </summary>
|
||||
/// <returns><see cref="Good"/> on success; otherwise a Bad code and a <c>null</c> value.</returns>
|
||||
private static uint Coerce(string raw, DriverDataType type, out object? value)
|
||||
{
|
||||
value = null;
|
||||
var text = raw.Trim();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
// Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
|
||||
// never a sensible authoring for MTConnect, but degrading it to text is harmless where
|
||||
// rejecting it would break a tag for no protection.
|
||||
case DriverDataType.String:
|
||||
case DriverDataType.Reference:
|
||||
value = raw;
|
||||
return Good;
|
||||
|
||||
case DriverDataType.Boolean:
|
||||
if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
|
||||
if (text is "1") { value = true; return Good; }
|
||||
if (text is "0") { value = false; return Good; }
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.DateTime:
|
||||
if (DateTime.TryParse(
|
||||
text,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
||||
out var moment))
|
||||
{
|
||||
value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
|
||||
return Good;
|
||||
}
|
||||
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.Float32:
|
||||
if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
|
||||
{
|
||||
value = single;
|
||||
return Good;
|
||||
}
|
||||
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.Float64:
|
||||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
|
||||
{
|
||||
value = real;
|
||||
return Good;
|
||||
}
|
||||
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.Int16:
|
||||
if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
|
||||
{
|
||||
value = i16;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.Int32:
|
||||
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
|
||||
{
|
||||
value = i32;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.Int64:
|
||||
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
|
||||
{
|
||||
value = i64;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.UInt16:
|
||||
if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
|
||||
{
|
||||
value = u16;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.UInt32:
|
||||
if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
|
||||
{
|
||||
value = u32;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.UInt64:
|
||||
if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
|
||||
{
|
||||
value = u64;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
// A DriverDataType this driver has no rule for. Text always round-trips, so serving
|
||||
// it beats failing a tag over an enum member added later.
|
||||
value = raw;
|
||||
return Good;
|
||||
}
|
||||
|
||||
// Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
|
||||
// operator whether to retype the tag or widen it.
|
||||
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
|
||||
? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
|
||||
: BadTypeMismatch; // not a number at all
|
||||
}
|
||||
}
|
||||
+706
@@ -0,0 +1,706 @@
|
||||
using System.Globalization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 8 — <see cref="MTConnectObservationIndex"/>: the thread-safe
|
||||
/// <c>dataItemId → DataValueSnapshot</c> map that <c>/current</c> and the <c>/sample</c> pump
|
||||
/// both write into, and the quality mapping that turns the Agent's weakly-typed text into an
|
||||
/// OPC UA-shaped snapshot.
|
||||
/// <para>
|
||||
/// Status codes are asserted as <b>literals</b> here on purpose. The production constants
|
||||
/// are <c>private</c>, so a wrong numeric value in the driver cannot be masked by both
|
||||
/// sides sharing one symbol — the literal is an independent statement of the OPC UA
|
||||
/// numeric contract (verified against <c>Opc.Ua.StatusCodes</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MTConnectObservationIndexTests
|
||||
{
|
||||
private const string CurrentFixture = "Fixtures/current.xml";
|
||||
private const string UnavailableFixture = "Fixtures/current-unavailable.xml";
|
||||
private const string SampleFixture = "Fixtures/sample.xml";
|
||||
|
||||
private const uint Good = 0x00000000u;
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
private const uint BadNotSupported = 0x803D0000u;
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Every dataItemId both /current fixtures report.</summary>
|
||||
private static readonly string[] AllFixtureDataItemIds =
|
||||
[
|
||||
"dev1_avail",
|
||||
"dev1_pos",
|
||||
"dev1_vibration_ts",
|
||||
"dev1_partcount",
|
||||
"dev1_execution",
|
||||
"dev1_program",
|
||||
"dev1_system_cond"
|
||||
];
|
||||
|
||||
private static MTConnectStreamsResult ParseFixture(string path) =>
|
||||
MTConnectStreamsParser.Parse(File.ReadAllText(path));
|
||||
|
||||
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
|
||||
private static MTConnectStreamsResult Synthetic(string dataItemId, string value, DateTime? timestampUtc = null) =>
|
||||
new(
|
||||
InstanceId: 1L,
|
||||
NextSequence: 2L,
|
||||
FirstSequence: 1L,
|
||||
Observations: [new MTConnectObservation(
|
||||
dataItemId,
|
||||
value,
|
||||
timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc))]);
|
||||
|
||||
private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
|
||||
|
||||
// --------------------------------------------------------------- UNAVAILABLE => BadNoCommunication
|
||||
|
||||
/// <summary>The plan's headline case: the Agent's UNAVAILABLE sentinel is a no-comms quality, not a value.</summary>
|
||||
[Fact]
|
||||
public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(UnavailableFixture));
|
||||
|
||||
var snap = idx.Get("dev1_partcount");
|
||||
|
||||
snap.Value.ShouldBeNull();
|
||||
snap.StatusCode.ShouldBe(0x80310000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every one of the seven observations in the all-UNAVAILABLE fixture maps to no-comms —
|
||||
/// including <c>dev1_system_cond</c>, which reaches the index as a CONDITION whose
|
||||
/// <c><Unavailable/></c> element name the parser normalized onto the exact literal
|
||||
/// <c>UNAVAILABLE</c>. A case-sensitive miss on that one spelling is the defect this covers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_observation_in_the_unavailable_fixture_maps_to_BadNoCommunication()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(UnavailableFixture));
|
||||
|
||||
foreach (var id in AllFixtureDataItemIds)
|
||||
{
|
||||
var snap = idx.Get(id);
|
||||
snap.StatusCode.ShouldBe(BadNoCommunication, $"dataItemId '{id}' should be no-comms");
|
||||
snap.Value.ShouldBeNull($"dataItemId '{id}' must not carry a value when unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An UNAVAILABLE observation still carries the Agent's timestamp — the quality changed, not the clock.</summary>
|
||||
[Fact]
|
||||
public void Unavailable_observation_keeps_the_agent_source_timestamp()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(UnavailableFixture));
|
||||
|
||||
var snap = idx.Get("dev1_partcount");
|
||||
|
||||
snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 10, 0, 300, DateTimeKind.Utc));
|
||||
snap.SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>The sentinel is matched exactly; a lowercase spelling is a real value, not a no-comms marker.</summary>
|
||||
[Theory]
|
||||
[InlineData("UNAVAILABLE", BadNoCommunication)]
|
||||
[InlineData("Unavailable", Good)]
|
||||
[InlineData("unavailable", Good)]
|
||||
public void Unavailable_sentinel_is_matched_on_the_exact_literal(string value, uint expectedStatus)
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
|
||||
idx.Apply(Synthetic("x", value));
|
||||
|
||||
idx.Get("x").StatusCode.ShouldBe(expectedStatus);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- present values
|
||||
|
||||
/// <summary>The plan's second headline case.</summary>
|
||||
[Fact]
|
||||
public void Present_value_indexes_good_with_source_timestamp()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_pos");
|
||||
|
||||
snap.StatusCode.ShouldBe(0u);
|
||||
snap.SourceTimestampUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>SourceTimestamp is the Agent's observation timestamp, never the indexing clock.</summary>
|
||||
[Fact]
|
||||
public void Present_value_carries_the_observation_timestamp_not_the_index_clock()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_pos");
|
||||
|
||||
snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
|
||||
snap.ServerTimestampUtc.ShouldBeGreaterThan(snap.SourceTimestampUtc!.Value);
|
||||
snap.ServerTimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>A Float64-authored SAMPLE coerces to a real double, not the raw text.</summary>
|
||||
[Fact]
|
||||
public void Float64_authored_tag_coerces_numeric_text_to_a_double()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_pos");
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBeOfType<double>().ShouldBe(123.4567);
|
||||
}
|
||||
|
||||
/// <summary>An Int64-authored EVENT (PartCount) coerces to a long. sample.xml is the fixture with a present count.</summary>
|
||||
[Fact]
|
||||
public void Int64_authored_tag_coerces_integer_text_to_a_long()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_partcount", DriverDataType.Int64)]);
|
||||
idx.Apply(ParseFixture(SampleFixture));
|
||||
|
||||
var snap = idx.Get("dev1_partcount");
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBeOfType<long>().ShouldBe(42L);
|
||||
}
|
||||
|
||||
/// <summary>String-authored EVENT/CONDITION observations carry the Agent's text verbatim.</summary>
|
||||
[Theory]
|
||||
[InlineData("dev1_execution", "ACTIVE")]
|
||||
[InlineData("dev1_program", "O1234")]
|
||||
[InlineData("dev1_avail", "AVAILABLE")]
|
||||
[InlineData("dev1_system_cond", "Normal")]
|
||||
public void String_authored_tags_carry_the_raw_agent_text(string dataItemId, string expected)
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag(dataItemId, DriverDataType.String)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get(dataItemId);
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBeOfType<string>().ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>The remaining scalar coercions, each landing on its exact CLR type.</summary>
|
||||
[Theory]
|
||||
[InlineData(DriverDataType.Boolean, "true", true)]
|
||||
[InlineData(DriverDataType.Boolean, "FALSE", false)]
|
||||
[InlineData(DriverDataType.Boolean, "1", true)]
|
||||
[InlineData(DriverDataType.Boolean, "0", false)]
|
||||
[InlineData(DriverDataType.Int16, "-12", (short)-12)]
|
||||
[InlineData(DriverDataType.Int32, "70000", 70000)]
|
||||
[InlineData(DriverDataType.UInt16, "65535", (ushort)65535)]
|
||||
[InlineData(DriverDataType.UInt32, "4000000000", 4000000000u)]
|
||||
[InlineData(DriverDataType.UInt64, "18446744073709551615", 18446744073709551615ul)]
|
||||
[InlineData(DriverDataType.Float32, "1.5", 1.5f)]
|
||||
public void Scalar_coercions_land_on_the_authored_clr_type(
|
||||
DriverDataType type, string text, object expected)
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("x", type)]);
|
||||
idx.Apply(Synthetic("x", text));
|
||||
|
||||
var snap = idx.Get("x");
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>A DateTime-authored tag parses ISO-8601 and lands on UTC kind.</summary>
|
||||
[Fact]
|
||||
public void DateTime_authored_tag_coerces_to_a_utc_datetime()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.DateTime)]);
|
||||
idx.Apply(Synthetic("x", "2026-07-24T12:00:00.250Z"));
|
||||
|
||||
var snap = idx.Get("x");
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
var value = snap.Value.ShouldBeOfType<DateTime>();
|
||||
value.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 250, DateTimeKind.Utc));
|
||||
value.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Numeric coercion is culture-invariant. On a de-DE machine a naive
|
||||
/// <c>double.TryParse("123.4567")</c> yields 1234567 — a silently wrong value with Good
|
||||
/// quality, the worst possible outcome. Run on a dedicated thread so the culture change
|
||||
/// cannot leak into any parallel test.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Numeric_coercion_is_culture_invariant()
|
||||
{
|
||||
Exception? failure = null;
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
|
||||
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
idx.Get("dev1_pos").Value.ShouldBeOfType<double>().ShouldBe(123.4567);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
});
|
||||
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
failure.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- coercion failures
|
||||
|
||||
/// <summary>
|
||||
/// A vocabulary EVENT authored as a number cannot parse. It must degrade to a Bad-coded
|
||||
/// snapshot — never an exception (the index sits under <c>IReadable</c>), and never a
|
||||
/// silently-wrong zero.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Coercion_failure_yields_BadTypeMismatch_and_never_throws()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
|
||||
|
||||
Should.NotThrow(() => idx.Apply(ParseFixture(CurrentFixture)));
|
||||
|
||||
var snap = idx.Get("dev1_execution");
|
||||
|
||||
snap.StatusCode.ShouldBe(0x80740000u);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Non-numeric text is a type mismatch; a number the authored type cannot hold is out of range.</summary>
|
||||
[Theory]
|
||||
[InlineData(DriverDataType.Float64, "ACTIVE", BadTypeMismatch)]
|
||||
[InlineData(DriverDataType.Int64, "ACTIVE", BadTypeMismatch)]
|
||||
[InlineData(DriverDataType.Boolean, "ACTIVE", BadTypeMismatch)]
|
||||
[InlineData(DriverDataType.Int16, "99999999999", BadOutOfRange)]
|
||||
[InlineData(DriverDataType.Int32, "3.5", BadOutOfRange)]
|
||||
[InlineData(DriverDataType.UInt32, "-1", BadOutOfRange)]
|
||||
public void Failed_coercion_distinguishes_type_mismatch_from_out_of_range(
|
||||
DriverDataType type, string text, uint expectedStatus)
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("x", type)]);
|
||||
idx.Apply(Synthetic("x", text));
|
||||
|
||||
var snap = idx.Get("x");
|
||||
|
||||
snap.StatusCode.ShouldBe(expectedStatus);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A Bad-coded coercion failure still reports when the Agent observed it.</summary>
|
||||
[Fact]
|
||||
public void Failed_coercion_keeps_the_agent_source_timestamp()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
idx.Get("dev1_execution").SourceTimestampUtc
|
||||
.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 500, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
/// <summary>An observation whose text is empty is "the Agent supplied no value" — never a Good empty string.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Empty_observation_value_maps_to_BadNoCommunication(string value)
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
|
||||
idx.Apply(Synthetic("x", value));
|
||||
|
||||
var snap = idx.Get("x");
|
||||
|
||||
snap.StatusCode.ShouldBe(BadNoCommunication);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- unknown lookups
|
||||
|
||||
/// <summary>A dataItemId that is neither authored nor ever observed is unknown to this driver.</summary>
|
||||
[Fact]
|
||||
public void Unknown_dataItemId_yields_BadNodeIdUnknown()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("no_such_data_item");
|
||||
|
||||
snap.StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
snap.Value.ShouldBeNull();
|
||||
snap.SourceTimestampUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An authored tag the Agent has not reported yet is a different condition from an unknown
|
||||
/// id — it is the standard OPC UA "subscribed, no value yet" state. Collapsing the two
|
||||
/// would tell an operator their correctly-authored tag does not exist.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Authored_but_never_observed_tag_yields_BadWaitingForInitialData()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_spindle_speed", DriverDataType.Float64)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_spindle_speed");
|
||||
|
||||
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>An empty / whitespace ref is not a lookup; it degrades rather than throwing.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Blank_dataItemId_yields_BadNodeIdUnknown(string dataItemId)
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
|
||||
idx.Get(dataItemId).StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- unauthored observations
|
||||
|
||||
/// <summary>
|
||||
/// The Agent streams the whole device, so most observations have no authored tag. They are
|
||||
/// indexed as their raw text under <see cref="DriverDataType.String"/> — the type that
|
||||
/// cannot fail to coerce — rather than dropped or coded Bad.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Unauthored_observation_is_indexed_as_a_good_string()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_pos");
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBeOfType<string>().ShouldBe("123.4567");
|
||||
}
|
||||
|
||||
/// <summary>Every fixture observation is indexed, authored or not — nothing is silently dropped.</summary>
|
||||
[Fact]
|
||||
public void Every_fixture_observation_is_indexed_even_with_no_authored_tags()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
|
||||
foreach (var id in AllFixtureDataItemIds)
|
||||
{
|
||||
idx.Get(id).StatusCode.ShouldNotBe(BadNodeIdUnknown, $"'{id}' should have been indexed");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------- TIME_SERIES
|
||||
|
||||
/// <summary>
|
||||
/// P1 defers TIME_SERIES array materialization to P1.5. An array-authored tag therefore
|
||||
/// reports BadNotSupported rather than pretending: the vector is real data this build
|
||||
/// cannot represent, which is neither a type mismatch nor a comms failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Time_series_authored_as_an_array_reports_BadNotSupported()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex(
|
||||
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_vibration_ts");
|
||||
|
||||
snap.StatusCode.ShouldBe(BadNotSupported);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The falsifiable half of the deferral: the vector must never be silently reduced to its
|
||||
/// first element. A Good 1.1 here would be a wrong value an operator would trust.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Time_series_vector_is_never_silently_parsed_as_its_first_element()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex(
|
||||
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_vibration_ts");
|
||||
|
||||
snap.Value.ShouldNotBe(1.1);
|
||||
snap.StatusCode.ShouldNotBe(Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-comms outranks the unsupported shape: an UNAVAILABLE array tag is a genuine, fully
|
||||
/// representable state and must report it rather than the deferral code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Unavailable_wins_over_the_unsupported_array_shape()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex(
|
||||
[new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
|
||||
idx.Apply(ParseFixture(UnavailableFixture));
|
||||
|
||||
idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNoCommunication);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unauthored TIME_SERIES observation carries the Agent's exact text as a string. That
|
||||
/// is the raw truth with no interpretation applied — not a scalar guess.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Unauthored_time_series_observation_carries_the_verbatim_vector_text()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
var snap = idx.Get("dev1_vibration_ts");
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBeOfType<string>().ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------- ordering
|
||||
|
||||
/// <summary>Within one Apply, the later observation for a dataItemId wins (Agent order is ascending sequence).</summary>
|
||||
[Fact]
|
||||
public void Last_write_wins_within_a_single_apply()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.Int64)]);
|
||||
|
||||
idx.Apply(new MTConnectStreamsResult(1L, 4L, 1L, [
|
||||
new MTConnectObservation("x", "1", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new MTConnectObservation("x", "2", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
|
||||
new MTConnectObservation("x", "3", new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc)),
|
||||
]));
|
||||
|
||||
var snap = idx.Get("x");
|
||||
|
||||
snap.Value.ShouldBe(3L);
|
||||
snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <c><Condition></c> container may carry several simultaneously-active states
|
||||
/// sharing one dataItemId (a Fault and a Warning at once), which the parser flattens into
|
||||
/// repeats in one result. Within a single document those states are concurrent, so the most
|
||||
/// severe wins — plain last-wins would under-report an active Fault as a Warning purely
|
||||
/// because of element order.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Fault", "Warning", "Fault")]
|
||||
[InlineData("Warning", "Fault", "Fault")]
|
||||
[InlineData("Normal", "Warning", "Warning")]
|
||||
[InlineData("Warning", "Normal", "Warning")]
|
||||
[InlineData("Fault", "Normal", "Fault")]
|
||||
public void Simultaneous_condition_states_keep_the_most_severe_within_one_apply(
|
||||
string first, string second, string expected)
|
||||
{
|
||||
var idx = new MTConnectObservationIndex(
|
||||
[new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
|
||||
|
||||
idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
|
||||
new MTConnectObservation("c", first, new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new MTConnectObservation("c", second, new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
|
||||
]));
|
||||
|
||||
idx.Get("c").Value.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>An active fault carries more information than "no value"; it outranks UNAVAILABLE.</summary>
|
||||
[Fact]
|
||||
public void An_active_fault_outranks_unavailable_within_one_apply()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex(
|
||||
[new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
|
||||
|
||||
idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
|
||||
new MTConnectObservation("c", "UNAVAILABLE", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new MTConnectObservation("c", "Fault", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
|
||||
]));
|
||||
|
||||
var snap = idx.Get("c");
|
||||
|
||||
snap.StatusCode.ShouldBe(Good);
|
||||
snap.Value.ShouldBe("Fault");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worst-of is scoped to ONE document. A later document is the Agent's new statement of the
|
||||
/// condition's state, so a cleared fault must fall back to Normal — a worst-of that spanned
|
||||
/// Applies would latch every fault forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_later_apply_clears_a_condition_to_its_new_state()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex(
|
||||
[new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
|
||||
|
||||
idx.Apply(Synthetic("c", "Fault"));
|
||||
idx.Get("c").Value.ShouldBe("Fault");
|
||||
|
||||
idx.Apply(Synthetic("c", "Normal"));
|
||||
|
||||
idx.Get("c").Value.ShouldBe("Normal");
|
||||
}
|
||||
|
||||
/// <summary>Severity reconciliation is for condition words only; repeated ordinary values stay last-wins.</summary>
|
||||
[Fact]
|
||||
public void Repeated_non_condition_values_stay_last_wins()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
|
||||
|
||||
idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
|
||||
new MTConnectObservation("x", "Fault", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new MTConnectObservation("x", "O1234", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
|
||||
]));
|
||||
|
||||
idx.Get("x").Value.ShouldBe("O1234");
|
||||
}
|
||||
|
||||
/// <summary>A later Apply overwrites an earlier one — including Good going to no-comms.</summary>
|
||||
[Fact]
|
||||
public void A_later_apply_overwrites_an_earlier_one()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
|
||||
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
idx.Get("dev1_pos").StatusCode.ShouldBe(Good);
|
||||
|
||||
idx.Apply(ParseFixture(UnavailableFixture));
|
||||
idx.Get("dev1_pos").StatusCode.ShouldBe(BadNoCommunication);
|
||||
}
|
||||
|
||||
/// <summary>An Apply carrying no observations leaves the index untouched (an idle /sample heartbeat).</summary>
|
||||
[Fact]
|
||||
public void An_observation_free_apply_leaves_the_index_untouched()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
idx.Apply(new MTConnectStreamsResult(1L, 200L, 1L, []));
|
||||
|
||||
idx.Get("dev1_pos").Value.ShouldBe(123.4567);
|
||||
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
|
||||
}
|
||||
|
||||
/// <summary>Clear drops every indexed observation — the Agent-restart re-baseline Task 11 needs.</summary>
|
||||
[Fact]
|
||||
public void Clear_drops_every_indexed_observation()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
|
||||
|
||||
idx.Clear();
|
||||
|
||||
idx.Count.ShouldBe(0);
|
||||
idx.Get("dev1_pos").StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ authored-tag map
|
||||
|
||||
/// <summary>
|
||||
/// Nothing enforces FullName uniqueness at the options layer, so a duplicate is operator-
|
||||
/// authorable. Last definition wins rather than throwing — refusing to construct would turn
|
||||
/// an authoring slip into a driver that will not start.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Duplicate_authored_fullname_takes_the_last_definition()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([
|
||||
Tag("dev1_pos", DriverDataType.String),
|
||||
Tag("dev1_pos", DriverDataType.Float64),
|
||||
]);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
idx.Get("dev1_pos").Value.ShouldBeOfType<double>().ShouldBe(123.4567);
|
||||
}
|
||||
|
||||
/// <summary>The index binds to the options' Tags collection, which defaults to empty and is never null.</summary>
|
||||
[Fact]
|
||||
public void Index_can_be_built_from_driver_options()
|
||||
{
|
||||
var options = new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://agent:5000",
|
||||
Tags = [Tag("dev1_pos", DriverDataType.Float64)],
|
||||
};
|
||||
|
||||
var idx = new MTConnectObservationIndex(options.Tags);
|
||||
idx.Apply(ParseFixture(CurrentFixture));
|
||||
|
||||
idx.Get("dev1_pos").Value.ShouldBe(123.4567);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- thread safety
|
||||
|
||||
/// <summary>
|
||||
/// /current (Task 10) and the /sample pump (Task 11) write concurrently while OPC UA reads
|
||||
/// snapshot. Per-key atomicity is the contract: no throw, no torn snapshot (a Good value
|
||||
/// paired with a Bad code), and every read observes one whole snapshot.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_applies_and_reads_stay_consistent_and_never_throw()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
|
||||
var current = ParseFixture(CurrentFixture);
|
||||
var unavailable = ParseFixture(UnavailableFixture);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
var writers = Enumerable.Range(0, 4).Select(i => Task.Run(() =>
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
idx.Apply(i % 2 == 0 ? current : unavailable);
|
||||
}
|
||||
}));
|
||||
|
||||
var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
var snap = idx.Get("dev1_pos");
|
||||
|
||||
// Exactly two snapshots are legal for this tag; anything else is a tear.
|
||||
if (snap.StatusCode == 0u)
|
||||
{
|
||||
snap.Value.ShouldBe(123.4567);
|
||||
}
|
||||
else
|
||||
{
|
||||
snap.StatusCode.ShouldBe(BadNoCommunication);
|
||||
snap.Value.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
await Task.WhenAll(writers.Concat(readers));
|
||||
|
||||
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
|
||||
}
|
||||
|
||||
/// <summary>Applying a null result is a programming error, not degradable data.</summary>
|
||||
[Fact]
|
||||
public void Apply_rejects_a_null_result()
|
||||
{
|
||||
var idx = new MTConnectObservationIndex();
|
||||
|
||||
Should.Throw<ArgumentNullException>(() => idx.Apply(null!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user