using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Review 2026-10-21 R06:T-6 — writes were one POST per slot although the Agent's PUT surface accepts several /// id=value pairs per device. Surviving slots now go out as one request per device, with a refused /// multi-slot request retried slot by slot so one refused DataItem still costs only its own slot. And the health /// contract: an Agent answer (accepted or refused) is the Agent talking, so it advances LastSuccessfulRead. /// public sealed class MTConnectBatchedWriteTests { private const string FixtureDeviceName = "VMC-3Axis"; private const string PosRaw = "Plant/MTConnect/dev1/Position"; private const string CountRaw = "Plant/MTConnect/dev1/PartCount"; private const string ProgramRaw = "Plant/MTConnect/dev1/Program"; private const uint Good = 0x00000000u; private const uint BadCommunicationError = 0x80050000u; private static CancellationToken Ct => TestContext.Current.CancellationToken; private static RawTagEntry Entry(string rawPath, string dataItemId) => new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1"); private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableAsync() { var client = CannedAgentClient.FromFixtures(); var driver = new MTConnectDriver( new MTConnectDriverOptions { AgentUri = "http://fixture-agent:5000", Tags = [], RawTags = [ Entry(PosRaw, "dev1_pos"), Entry(CountRaw, "dev1_partcount"), Entry(ProgramRaw, "dev1_program"), ], Write = new MTConnectWriteOptions { Enabled = true }, }, "mt-t6", _ => client); await driver.InitializeAsync("{}", Ct); return (driver, client); } /// Three writes to three DataItems of one device are ONE Agent request carrying three pairs. [Fact] public async Task Writes_to_one_device_go_out_as_one_request() { var (driver, client) = await WritableAsync(); var results = await driver.WriteAsync( [new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct); results.ShouldAllBe(r => r.StatusCode == Good); var request = client.WriteRequests.ShouldHaveSingleItem(); request.DeviceName.ShouldBe(FixtureDeviceName); request.Pairs.Select(p => (p.Key, p.Value)).ShouldBe( [("dev1_pos", "1.5"), ("dev1_partcount", "7"), ("dev1_program", "O1000")]); } /// Two writes to the SAME DataItem are never merged into one request — they land in request order. [Fact] public async Task Two_writes_to_one_data_item_stay_separate_and_ordered() { var (driver, client) = await WritableAsync(); await driver.WriteAsync([new WriteRequest(PosRaw, 1.0), new WriteRequest(CountRaw, 3), new WriteRequest(PosRaw, 2.0)], Ct); client.WriteRequests.Count.ShouldBe(2); client.Writes.Where(w => w.DataItemId == "dev1_pos").Select(w => w.Value).ShouldBe(["1", "2"]); } /// A refused multi-slot request is retried per slot: only the refused DataItem's slot fails. [Fact] public async Task A_refused_batch_is_retried_per_slot_so_only_the_refused_item_fails() { var (driver, client) = await WritableAsync(); client.RefusedDataItems.Add("dev1_partcount"); var results = await driver.WriteAsync( [new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct); results.Select(r => r.StatusCode).ShouldBe([Good, BadCommunicationError, Good]); client.WriteRequests.Count.ShouldBe(4, "the batch, then one retry per slot"); } /// A refusal is a reply: it advances LastSuccessfulRead (the Agent answered). [Fact] public async Task A_refused_write_is_an_answer_and_advances_the_stamp() { var (driver, client) = await WritableAsync(); client.RefusedDataItems.Add("dev1_pos"); var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); // The stamp is DateTime.UtcNow: wait (bounded) for the clock to tick past it so an advance is observable. var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield(); await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct); driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } /// Review I-3 (coordinator ruling): a write that got NO reply degrades health, with the stamp preserved. [Fact] public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp() { var (driver, client) = await WritableAsync(); var before = driver.GetHealth(); before.State.ShouldBe(DriverState.Healthy, "positive control: Healthy before the failed write"); client.WriteFailure = new HttpRequestException("connection refused"); var results = await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct); results.Single().StatusCode.ShouldBe(BadCommunicationError); var h = driver.GetHealth(); h.State.ShouldBe(DriverState.Degraded); h.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); h.LastError.ShouldNotBeNull().ShouldContain(nameof(HttpRequestException)); } /// The unanswered-write degradation is cleared by the next ANSWERED write (it cannot latch). [Fact] public async Task An_answered_write_clears_an_unanswered_write_degradation() { var (driver, client) = await WritableAsync(); client.WriteFailure = new HttpRequestException("connection refused"); await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct); driver.GetHealth().State.ShouldBe(DriverState.Degraded); client.WriteFailure = null; await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } /// …and by the next good READ (the other direction of the shared health writer). [Fact] public async Task A_good_read_clears_an_unanswered_write_degradation() { var (driver, client) = await WritableAsync(); client.WriteFailure = new HttpRequestException("connection refused"); await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct); driver.GetHealth().State.ShouldBe(DriverState.Degraded); (await driver.ReadAsync([PosRaw], Ct)).Single().StatusCode.ShouldBe(Good); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } /// The converse: a failed READ is not cleared by an answered write (each path clears only what it proves). [Fact] public async Task An_answered_write_does_not_clear_a_failed_read() { var (driver, client) = await WritableAsync(); client.CurrentFailure = new HttpRequestException("connection refused"); await driver.ReadAsync([PosRaw], Ct); driver.GetHealth().State.ShouldBe(DriverState.Degraded); await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct); driver.GetHealth().State.ShouldBe(DriverState.Degraded); } /// /// r2 N-5 (coordinator ruling): on a subscription-only driver an unanswered write degrades, and the next delivered /// /sample chunk restores Healthy (a delivered chunk proves the Agent is alive). Positive control: with no chunk /// it stays Degraded. /// [Fact] public async Task A_delivered_chunk_clears_an_unanswered_write_and_without_one_it_stays_degraded() { var (driver, client) = await WritableAsync(); try { await driver.SubscribeAsync([PosRaw], TimeSpan.FromMilliseconds(50), Ct); await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5")).WaitAsync(TimeSpan.FromSeconds(30), Ct); client.WriteFailure = new HttpRequestException("connection refused"); await driver.WriteAsync([new WriteRequest(PosRaw, 9.5)], Ct); driver.GetHealth().State.ShouldBe(DriverState.Degraded); // Positive control: nothing else happens — it stays Degraded (no read, no write, no chunk). var currentCalls = client.CurrentCallCount; driver.GetHealth().State.ShouldBe(DriverState.Degraded); client.CurrentCallCount.ShouldBe(currentCalls); await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(113, 118, "2.5")).WaitAsync(TimeSpan.FromSeconds(30), Ct); driver.GetHealth().State.ShouldBe(DriverState.Healthy, "a delivered chunk proves the Agent is alive"); } finally { client.WriteFailure = null; await driver.ShutdownAsync(CancellationToken.None); } } }