Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs
T
Joseph Doherty 8a80dcc787 fix(ablegacy): a status the PLC returned is an answer, never a comms fault
Review r0 I1. On the default libplctag transport a PLC refusal (non-zero PCCC
STS, e.g. an element past the end of the file; or a CIP error) arrives as
ERR_REMOTE_ERR / ERR_BAD_STATUS, which fell into the BadConnection default: a
no-reply comms fault. A coalesced block with one such member then failed as a
group every poll, and two refused tags in a row opened the device's fail-fast
window against a device that was answering.

- LibplctagLegacyStatusTranslation maps ERR_REMOTE_ERR / ERR_BAD_STATUS to the
  existing AbStatus.DeviceStateConflict (BadDeviceFailure): "reachable, refused
  the operation". No new AbStatus value (Driver.Ab.Transport is Lane K's).
- IsNoReply (Timeout / BadConnection / Abort / Pending / unknown codes) is now
  the ONLY comms-fault class; IsDeviceRefusal is every other non-Ok status
  except Unsupported (BadReply / Partial included: a reply arrived).
- A coalesced block refused with ANY returned status falls back to per-member
  reads in the same pass; the shape is remembered only after a repeat, and the
  refused block's handle is always evicted (M5).
- A runtime status Unsupported is neutral (keeps the handle); the probe no
  longer counts it as a fault.
- Refusal warnings log the status actually answered (M8).

Live: the new past-end-of-file ab_server case fails on libplctag with the old
translation and passes with it, on slc500/micrologix/plc5 x both transports.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:12:21 -04:00

110 lines
5.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
/// otherwise compares whatever the simulator holds.
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
{
/// <summary>Coalesced and per-tag reads of the same tags agree value for value.</summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
var ct = TestContext.Current.CancellationToken;
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
AbLegacyTagDefinition[] tags =
[
new("W0", uri, "N7:0", AbLegacyDataType.Int),
new("W1", uri, "N7:1", AbLegacyDataType.Int),
new("W4", uri, "N7:4", AbLegacyDataType.Int),
new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
];
string[] refs = [.. tags.Select(t => t.Name)];
AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(tags),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
CoalesceReads = coalesce,
}, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
await using var single = Make(coalesce: false);
await using var coalesced = Make(coalesce: true);
await single.InitializeAsync("{}", ct);
await coalesced.InitializeAsync("{}", ct);
var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
&& Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
&& !writesRefused)
{
(await single.WriteAsync(
[new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
}
var a = await single.ReadAsync(refs, ct);
var b = await coalesced.ReadAsync(refs, ct);
a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
}
/// <summary>
/// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10
/// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its
/// neighbours must still read Good on every poll, and the device must never be failed fast or degraded.
/// </summary>
/// <param name="profile">The simulator profile.</param>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours(
AbLegacyServerProfile profile, AbLegacyTransport transport)
{
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
var ct = TestContext.Current.CancellationToken;
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int),
new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int),
new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
}, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}");
await drv.InitializeAsync("{}", ct);
for (var poll = 0; poll < 3; poll++)
{
var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A");
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B");
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag");
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadCommunicationError,
$"poll {poll}: the PLC answered — not a comms fault (status 0x{r[2].StatusCode:X8})");
}
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}