fix(mtconnect): deterministic concurrency test + DATA_SET -> BadNotSupported

The concurrency test asserted a post-Apply post-condition from inside the
race window: dev1_pos is authored, so a reader beating the first Apply
correctly observes BadWaitingForInitialData, which the test's two-member
"legal" set omitted. Consistently red (10/10) rather than intermittent.

Rewritten to assert only properties that hold at every instant — the whole
(status, value, source timestamp) triple against the three legal snapshots,
which is the real torn-snapshot check — plus a read counter guarding a
vacuous pass and one deterministic closing Apply for an exact end state.
The index is unchanged here; BadWaitingForInitialData is a designed state.

Also codes structured (DATA_SET / TABLE) observations BadNotSupported now
that the parser supplies MTConnectObservation.IsStructured, so a two-entry
data set no longer surfaces as the Good string "12".
This commit is contained in:
Joseph Doherty
2026-07-24 15:09:42 -04:00
parent 712635ce8c
commit 3f8f3e1863
2 changed files with 156 additions and 30 deletions
@@ -48,20 +48,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// 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>&lt;Entry key=…&gt;</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).
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
/// real content in <c>&lt;Entry key=…&gt;</c> child elements, which reach a text-valued
/// observation concatenated with the keys discarded — a two-entry set reads as the single
/// token <c>"12"</c> — and because the type inference correctly demotes those
/// representations to <see cref="DriverDataType.String"/>, coercion would otherwise
/// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
/// itself (no value heuristic can separate concatenated entries from a legitimate
/// space-bearing EVENT such as a <c>Message</c> reading "Coolant level low"), so it relies on
/// <see cref="MTConnectObservation.IsStructured"/>, which
/// <see cref="MTConnectStreamsParser"/> sets while the XML is still XML.
/// </para>
/// </remarks>
internal sealed class MTConnectObservationIndex
@@ -257,6 +255,17 @@ internal sealed class MTConnectObservationIndex
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
}
// DATA_SET / TABLE: the Agent carried the content in <Entry key="…"> child elements, so
// Value is their concatenated text with the keys discarded (a two-entry set reads "12").
// Publishing that as a Good string would be noise an operator would trust. Checked before
// the tag lookup because it is a fact about the wire shape, true whether or not the data
// item is authored — and after the sentinel, so an unavailable data set still reports the
// truthful no-comms.
if (observation.IsStructured)
{
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
}
_tags.TryGetValue(observation.DataItemId, out var tag);
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA