Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBatchedWriteTests.cs
T
Joseph Doherty f05e3bf6f4 perf(mtconnect): one Agent write request per device (R06:T-6)
Writes were one POST per slot although the Agent's PUT surface accepts several
id=value pairs. The slots surviving the local refusals now go out as one
WriteObservationsAsync(device, pairs) per device (new IMTConnectAgentClient method;
the default loops the single-pair call). A batch closes at another device or a
repeated DataItem so same-item writes keep request order; a refused multi-slot
request is retried per slot so one refused DataItem costs only its own slot.
Health contract: any Agent answer (accepted or refused) advances
LastSuccessfulRead with the state following the read / stream flags.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:55:37 -04:00

108 lines
4.9 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Review 2026-10-21 R06:T-6 — writes were one POST per slot although the Agent's PUT surface accepts several
/// <c>id=value</c> 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.
/// </summary>
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);
}
/// <summary>Three writes to three DataItems of one device are ONE Agent request carrying three pairs.</summary>
[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")]);
}
/// <summary>Two writes to the SAME DataItem are never merged into one request — they land in request order.</summary>
[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"]);
}
/// <summary>A refused multi-slot request is retried per slot: only the refused DataItem's slot fails.</summary>
[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");
}
/// <summary>A refusal is a reply: it advances LastSuccessfulRead (the Agent answered).</summary>
[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);
}
}