Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs
T
Joseph Doherty f37cb2a4a3 fix(abcip): only a reply type settles the BOOL-array layout (review r2)
N1: a refusal of the DWORD-addressed request permanently settled the layout
as per-element, so a tag deployed before the online edit that creates its
BOOL array later read DWORD n and published byte 0 as Good. The native runtime
now keeps an explicit layout (Unsettled / Packed / PerElement) that ONLY a
reply type changes: 0xD3 settles packed (Bits[n/32], bit n%32), 0xC1 settles
per-element (Bits[n]). Refusals and comms faults leave it unsettled, so the
next read asks again. A reply whose type disagrees with the addressing it was
asked under is discarded and re-read once under the shown layout (it would be
some other element), also when an online edit re-types a settled array. A
scalar BOOL answered 0xD3 with no element bit, or not settled packed, is
refused BadTypeMismatch instead of decoding byte 0.

m1: the write-owned Degraded marker is set together with the CAS that installs
it, under one lock, so concurrent failing writes cannot leave it naming a
different instance (a good write then clears it); a refusal-only read batch
still keeps it write-owned.
m2/m3/m5: documented (breaker accounting of a superseded init; libplctag
throw-only NotImplemented assumption; the TypeMismatch write status word is
libplctag-only).
m4: test for a write on an unsettled layout (settles by a read first).

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:05:00 -04:00

176 lines
7.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 H19: write-side health. A client value that cannot be converted never touched the wire, so it must
/// not latch <c>Degraded</c>; and a good write must not overwrite a <c>Degraded</c> the read path just recorded
/// with <c>Healthy</c> (the batch read path is worst-wins for exactly this reason). A good write proves the device
/// answered, so it advances <c>LastSuccessfulRead</c> without touching the state.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipWriteHealthTests
{
private const string Device = "ab://10.0.0.5/1,0";
private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override Task WriteAsync(CancellationToken cancellationToken) =>
throw new InvalidOperationException("socket reset");
}
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
Func<AbCipTagCreateParams, FakeAbCipTag>? make = null)
{
var factory = new FakeAbCipTagFactory
{
Customise = make ?? (p => p.TagName == "Broken"
? new FakeAbCipTag(p) { ThrowOnRead = true }
: new RealEncodingTag(p) { Value = 0 }),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt),
new AbCipTagDefinition("I", Device, "I", AbCipDataType.Int),
new AbCipTagDefinition("Broken", Device, "Broken", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-write-health", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
/// <summary>A fake whose encode converts like the real runtimes do (Convert.ToInt32 / ToInt16).</summary>
private sealed class RealEncodingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
Value = type switch
{
AbCipDataType.Int => Convert.ToInt16(value),
_ => Convert.ToInt32(value),
};
}
/// <summary>Unconvertible / out-of-range client values: refused per tag, health untouched, nothing sent.</summary>
[Fact]
public async Task A_client_conversion_error_writes_no_health()
{
var (drv, factory) = await BuildAsync();
var before = drv.GetHealth();
var r = await drv.WriteAsync(
[
new WriteRequest("D", "not a number"), // FormatException
new WriteRequest("D", new object()), // InvalidCastException
new WriteRequest("I", 1_000_000), // OverflowException
], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[2].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
drv.GetHealth().ShouldBe(before);
factory.Tags["D"].WriteCount.ShouldBe(0);
factory.Tags["I"].WriteCount.ShouldBe(0);
}
/// <summary>A good write keeps a Degraded the read path recorded, and advances LastSuccessfulRead.</summary>
[Fact]
public async Task A_good_write_does_not_overwrite_Degraded_with_Healthy()
{
var (drv, _) = await BuildAsync();
await drv.ReadAsync(["Broken"], CancellationToken.None);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
await Task.Delay(20);
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastError.ShouldBe(degraded.LastError);
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); // the device answered
}
/// <summary>Positive control: a genuine write transport fault still degrades and preserves LastSuccessfulRead.</summary>
[Fact]
public async Task A_write_transport_fault_still_degrades()
{
var (drv, factory) = await BuildAsync(p => new ThrowingWriteTag(p));
var before = drv.GetHealth();
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
factory.Tags["D"].Disposed.ShouldBeTrue();
}
/// <summary>M3: a good write clears a Degraded that a WRITE comms fault recorded.</summary>
[Fact]
public async Task A_good_write_clears_a_write_side_Degraded()
{
var fail = true;
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
(await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fail = false; // the evicted runtime is re-created healthy
(await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastError.ShouldBeNull();
}
/// <summary>
/// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good
/// write still clears it.
/// </summary>
[Fact]
public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded()
{
var fail = true;
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
for (var round = 0; round < 20; round++)
{
fail = true;
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None))));
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fail = false;
(await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode
.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}");
}
}
/// <summary>
/// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a
/// good write still clears it.
/// </summary>
[Fact]
public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned()
{
var fail = true;
var (drv, _) = await BuildAsync(p => p.TagName == "Broken"
? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound }
: fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
(await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
fail = false;
await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}