using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Task 10 — 's half: one /current /// per batch, one snapshot per requested reference in request order, and a Bad code for every /// shape of failure. Every test runs against ; no socket is /// opened anywhere in this file. /// /// /// /// The load-bearing assertions are about arity, order, and not-throwing. The caller /// (the runtime's dispatch layer) indexes the returned list positionally against the /// references it asked for, so a length mismatch or a reorder is silent data corruption — /// every value lands on the wrong tag under Good quality. And every failure this driver can /// meet at read time (agent down, deadline blown, unparseable answer, unknown id, driver not /// initialized) must arrive as a Bad-coded snapshot: an exception out of /// fails the whole batch including the references that /// were perfectly readable. /// /// /// The one exception that may propagate is genuine caller cancellation, and it is /// pinned here alongside its opposite (an agent failure under an uncancelled token) — the /// two are one catch filter apart in the implementation, and getting the filter /// backwards turns every dead agent into a thrown batch. /// /// public sealed class MTConnectReadTests { private const string AgentUri = "http://fixture-agent:5000"; // Canonical Opc.Ua.StatusCodes numerics, restated here so the test asserts the wire value an // OPC UA client actually sees rather than a driver-private constant it could drift with. private const uint Good = 0x00000000u; private const uint BadMask = 0x80000000u; private const uint BadCommunicationError = 0x80050000u; private const uint BadNoCommunication = 0x80310000u; private const uint BadWaitingForInitialData = 0x80320000u; private const uint BadNodeIdUnknown = 0x80340000u; private const uint BadNotSupported = 0x803D0000u; private const uint BadNotConnected = 0x808A0000u; /// /// Authored tags spanning every read outcome the fixtures can produce: a coercible Good /// value, an UNAVAILABLE one, a TIME_SERIES vector, and one authored id no fixture /// ever reports. /// private static MTConnectDriverOptions Opts(int requestTimeoutMs = 5000) => new() { AgentUri = AgentUri, RequestTimeoutMs = requestTimeoutMs, Tags = [ new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), new MTConnectTagDefinition("dev1_execution", DriverDataType.String), new MTConnectTagDefinition("dev1_partcount", DriverDataType.Int32), new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10), new MTConnectTagDefinition("dev1_never_reported", DriverDataType.Int32), ], }; private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver( MTConnectDriverOptions? options = null) { var client = CannedAgentClient.FromFixtures(); return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client); } private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync( MTConnectDriverOptions? options = null) { var (driver, client) = NewDriver(options); await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); return (driver, client); } // ---- arity + order: the contract the caller indexes positionally ---- /// The plan's TDD case: one snapshot per ref, in order, absent ⇒ Bad rather than a throw. [Fact] public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync(["dev1_pos", "does-not-exist"], TestContext.Current.CancellationToken); res.Count.ShouldBe(2); res[0].StatusCode.ShouldBe(Good); (res[1].StatusCode & BadMask).ShouldBe(BadMask); } /// /// A mixed batch requested in an order that matches neither the document order nor any /// dictionary enumeration order. This is the assertion that catches a driver that answers /// with the index's own key order instead of the request's. /// [Fact] public async Task Read_answers_in_request_order_not_document_order() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync( ["dev1_program", "dev1_pos", "does-not-exist", "dev1_partcount", "dev1_execution"], TestContext.Current.CancellationToken); res.Count.ShouldBe(5); // dev1_program is observed but NOT authored — String is the only coercion that cannot fail. res[0].StatusCode.ShouldBe(Good); res[0].Value.ShouldBe("O1234"); res[1].StatusCode.ShouldBe(Good); res[1].Value.ShouldBe(123.4567d); res[2].StatusCode.ShouldBe(BadNodeIdUnknown); res[3].StatusCode.ShouldBe(BadNoCommunication); res[4].StatusCode.ShouldBe(Good); res[4].Value.ShouldBe("ACTIVE"); } /// /// A reference repeated in one request gets a snapshot per occurrence. De-duplicating would /// shorten the list and shift every later reference onto the wrong tag. /// [Fact] public async Task Read_returns_a_snapshot_per_duplicate_occurrence() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync( ["dev1_pos", "dev1_execution", "dev1_pos"], TestContext.Current.CancellationToken); res.Count.ShouldBe(3); res[0].StatusCode.ShouldBe(Good); res[0].Value.ShouldBe(123.4567d); res[1].Value.ShouldBe("ACTIVE"); res[2].StatusCode.ShouldBe(Good); res[2].Value.ShouldBe(123.4567d); } /// An empty batch is empty — and, being empty, costs the Agent nothing. [Fact] public async Task Read_of_an_empty_batch_is_empty_and_costs_no_round_trip() { var (driver, client) = await InitializedDriverAsync(); var before = client.CurrentCallCount; var res = await driver.ReadAsync([], TestContext.Current.CancellationToken); res.Count.ShouldBe(0); client.CurrentCallCount.ShouldBe(before); } /// /// A batch of N references costs exactly ONE /current. The whole point of reading the /// Agent's whole-device snapshot is that per-tag round-trips never happen. /// [Fact] public async Task Read_of_many_refs_costs_exactly_one_current_call() { var (driver, client) = await InitializedDriverAsync(); var before = client.CurrentCallCount; var res = await driver.ReadAsync( ["dev1_pos", "dev1_execution", "dev1_partcount", "dev1_program", "does-not-exist", "dev1_pos"], TestContext.Current.CancellationToken); res.Count.ShouldBe(6); client.CurrentCallCount.ShouldBe(before + 1); } // ---- status-code fidelity: the index's distinctions must survive the trip ---- /// A genuinely unknown id — neither authored nor ever observed. [Fact] public async Task Read_of_an_unknown_ref_reports_BadNodeIdUnknown() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync(["nope"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadNodeIdUnknown); res[0].Value.ShouldBeNull(); } /// /// An authored id the Agent has never reported is BadWaitingForInitialData, NOT /// BadNodeIdUnknown — collapsing the two would tell an operator their correctly /// authored tag does not exist. /// [Fact] public async Task Read_of_an_authored_but_unreported_ref_reports_BadWaitingForInitialData() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync(["dev1_never_reported"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadWaitingForInitialData); } /// The Agent said UNAVAILABLE: reachable Agent, absent machine value. [Fact] public async Task Read_of_an_unavailable_ref_reports_BadNoCommunication() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync(["dev1_partcount"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadNoCommunication); res[0].Value.ShouldBeNull(); res[0].SourceTimestampUtc.ShouldNotBeNull(); } /// A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral). [Fact] public async Task Read_of_a_time_series_ref_reports_BadNotSupported() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadNotSupported); } /// A blank reference is data, not a programming error — it codes Bad, it does not throw. [Fact] public async Task Read_of_a_blank_ref_is_bad_not_a_throw() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync(["", " ", "dev1_pos"], TestContext.Current.CancellationToken); res.Count.ShouldBe(3); res[0].StatusCode.ShouldBe(BadNodeIdUnknown); res[1].StatusCode.ShouldBe(BadNodeIdUnknown); res[2].StatusCode.ShouldBe(Good); } /// The authored type is honoured: the Agent's text becomes a CLR value of that type. [Fact] public async Task Read_coerces_the_agent_text_to_the_authored_type() { var (driver, _) = await InitializedDriverAsync(); var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(Good); res[0].Value.ShouldBeOfType().ShouldBe(123.4567d); res[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc)); } // ---- freshness: a read is a read, not a replay of the initialize baseline ---- /// /// The whole justification for spending a round-trip: an OPC UA Read must reflect the /// device's current state, not whatever /// happened to prime. A driver that served the primed index would pass every other test in /// this file. /// [Fact] public async Task Read_reflects_the_agent_state_at_read_time_not_the_initialize_baseline() { var (driver, client) = await InitializedDriverAsync(); // The Agent has moved on since the priming /current (sample.xml reports 124.0100). client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml"); var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(Good); res[0].Value.ShouldBe(124.01d); } /// /// The anti-clobber pin. ReadAsync indexes its /current into a /// per-call snapshot and never writes the driver's shared observation index — that index has /// exactly one writer, the Task 11 /sample pump. If a read wrote it, a /current /// document older than the pump's newest delta would silently roll a subscribed value /// backwards and leave it there until the next change. /// [Fact] public async Task Read_does_not_write_the_shared_observation_index() { var (driver, client) = await InitializedDriverAsync(); var primed = driver.ObservationIndex.Get("dev1_pos"); client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml"); var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); // The read saw the new value... res[0].Value.ShouldBe(124.01d); // ...and the shared index still holds exactly what the pump/priming left there. var after = driver.ObservationIndex.Get("dev1_pos"); after.Value.ShouldBe(primed.Value); after.Value.ShouldBe(123.4567d); } // ---- failure posture: Bad snapshots, never exceptions ---- /// /// An unreachable Agent fails the batch's values, not the batch. Every reference codes /// BadCommunicationError and the call returns normally. /// [Fact] public async Task Read_codes_every_ref_bad_when_the_agent_call_fails() { var (driver, client) = await InitializedDriverAsync(); client.CurrentFailure = new HttpRequestException("connection refused"); var res = await driver.ReadAsync( ["dev1_pos", "dev1_execution", "does-not-exist"], TestContext.Current.CancellationToken); res.Count.ShouldBe(3); res.ShouldAllBe(r => r.StatusCode == BadCommunicationError); res.ShouldAllBe(r => r.Value == null); } /// An answer the driver cannot make sense of is a Bad batch, not a thrown one. [Fact] public async Task Read_codes_every_ref_bad_when_the_agent_answer_is_unusable() { var (driver, client) = await InitializedDriverAsync(); client.CurrentFailure = new System.Xml.XmlException("unexpected end of document"); var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadCommunicationError); } /// /// A failed read degrades the driver rather than leaving it advertising Healthy, and a later /// successful read restores it. LastSuccessfulRead survives the degradation — it is /// the "when did this driver last hear from the Agent" diagnostic. /// [Fact] public async Task Read_failure_degrades_the_driver_and_a_later_success_restores_it() { var (driver, client) = await InitializedDriverAsync(); client.CurrentFailure = new HttpRequestException("connection refused"); await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); var degraded = driver.GetHealth(); degraded.State.ShouldBe(DriverState.Degraded); degraded.LastSuccessfulRead.ShouldNotBeNull(); degraded.LastError.ShouldNotBeNullOrWhiteSpace(); client.CurrentFailure = null; await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// R2-01 — the /current runs under a bounded deadline. An Agent that accepts the /// request and never answers surfaces as a Bad batch on the driver's own clock rather than /// wedging the caller forever. /// [Fact] public async Task Read_is_bounded_by_the_per_call_deadline() { var (driver, client) = await InitializedDriverAsync(Opts(requestTimeoutMs: 50)); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.CurrentGate = gate; try { var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); res.Count.ShouldBe(1); res[0].StatusCode.ShouldBe(BadCommunicationError); } finally { gate.TrySetResult(); } } /// /// The one exception that may cross the capability boundary. Note the contrast with /// : same code path, one /// catch filter apart. /// [Fact] public async Task Read_propagates_genuine_caller_cancellation() { var (driver, _) = await InitializedDriverAsync(); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync( () => driver.ReadAsync(["dev1_pos"], cts.Token)); } // ---- reads outside the initialized window ---- /// /// A read before InitializeAsync has no Agent to ask. It reports /// BadNotConnected for every reference — it does not throw, it does not /// NullReferenceException, and it does not invent a value. /// [Fact] public async Task Read_before_initialize_is_bad_not_connected() { var (driver, client) = NewDriver(); var res = await driver.ReadAsync(["dev1_pos", "nope"], TestContext.Current.CancellationToken); res.Count.ShouldBe(2); res.ShouldAllBe(r => r.StatusCode == BadNotConnected); client.CurrentCallCount.ShouldBe(0); // An un-started driver is Unknown, not Degraded: nothing has failed yet. driver.GetHealth().State.ShouldBe(DriverState.Unknown); } /// /// Same posture after ShutdownAsync, and shutting down must stay reported as /// — a read attempted against a deliberately stopped /// driver is not a degradation. /// [Fact] public async Task Read_after_shutdown_is_bad_not_connected_and_leaves_health_alone() { var (driver, _) = await InitializedDriverAsync(); await driver.ShutdownAsync(TestContext.Current.CancellationToken); var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); res[0].StatusCode.ShouldBe(BadNotConnected); driver.GetHealth().State.ShouldBe(DriverState.Unknown); } /// /// A null reference list is a caller bug, not device data — the one place this method /// is allowed to be loud, matching 's documented /// posture ("the only exceptions raised are ArgumentNullException for a null argument"). /// [Fact] public async Task Read_of_a_null_ref_list_throws_ArgumentNullException() { var (driver, _) = await InitializedDriverAsync(); await Should.ThrowAsync( () => driver.ReadAsync(null!, TestContext.Current.CancellationToken)); } }