f05e3bf6f4
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
108 lines
4.9 KiB
C#
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);
|
|
}
|
|
}
|