feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8)

This commit is contained in:
Joseph Doherty
2026-07-24 14:50:43 -04:00
parent cf43f8d0ab
commit ac0a284055
2 changed files with 1112 additions and 0 deletions
@@ -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>&lt;Unavailable/&gt;</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>&lt;Condition&gt;</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!));
}
}