b2c064bec3
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
169 lines
7.6 KiB
C#
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);
|
|
}
|
|
}
|