Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBatchedWriteTests.cs
T
Joseph Doherty b2c064bec3 fix(mtconnect): an unanswered write degrades; one success writer for reads and writes (review I-3, m7)
Coordinator ruling on F3: a write with no reply (transport failure / deadline)
sets Degraded with the stamp preserved and a type-only LastError, through a new
_writeDegraded flag folded into PublishHealthy. One success writer is shared:
RecordReadAnswered (a /current answer, an Agent refusal, a live-index read)
clears the read AND write degradations; RecordWriteAnswered clears the write
one; neither clears the other path's stream degradation. Tests both directions
(answered write clears it, good read clears it) plus the converse (an answered
write does not clear a failed read); red with each half removed.
m7: a good /current resets the refusal-dedup latch, so a refusal recurring after a
recovery is logged again (tested).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:31:26 -04:00

169 lines
7.6 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);
}
/// <summary>Review I-3 (coordinator ruling): a write that got NO reply degrades health, with the stamp preserved.</summary>
[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));
}
/// <summary>The unanswered-write degradation is cleared by the next ANSWERED write (it cannot latch).</summary>
[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);
}
/// <summary>…and by the next good READ (the other direction of the shared health writer).</summary>
[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);
}
/// <summary>The converse: a failed READ is not cleared by an answered write (each path clears only what it proves).</summary>
[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);
}
}