e863e9f0e2
A first-touch native write to a T/C/R status-bit sub-element (T4:0.DN) created and initialised the tag's runtime before the runtime's write refusal could fire; the init is one READ, so against a device lacking the file (the ab_server fixture has no T/C/R) it surfaced as BadCommunicationError + Degraded health instead of BadNotSupported. The bit-RMW path was not involved: AbLegacyAddress rejects sub-element + /N, so a status bit's BitIndex is always null. NativeAbLegacyTagRuntime.WriteRefusal is now the single predicate (status bit by address whatever the authored type; any PLC-5 write), asked by the driver via IAbLegacyTagFactory.WriteRefusal (default null, so libplctag is unchanged) before EnsureTagRuntimeAsync and ahead of the bit-RMW branch. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
119 lines
6.9 KiB
C#
119 lines
6.9 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>
|
|
/// Wire-adjacent coverage of the native transport's deliberate refusals (#571): PLC-5 non-N reads, PLC-5
|
|
/// writes and status-bit sub-element writes (<c>T4:0.DN</c>). Each must surface as
|
|
/// <see cref="AbLegacyStatusMapper.BadNotSupported"/> and must not count as a comms fault
|
|
/// (health stays Healthy — the comms-loss watchdog reads it).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ <b>Which layer refuses.</b> These refusals are the DRIVER's, raised by <c>NativeAbLegacyTagRuntime</c>
|
|
/// before anything reaches the wire — the simulator is only needed so the driver has a device to hold a
|
|
/// handle against and so the positive control (an N-file read on the same driver) can prove the refusal is
|
|
/// per-tag rather than a dead connection. ab_server's PLC/5 mode covers N/F/B only, so a refusal that
|
|
/// came from the simulator would look the same from the outside; the positive control and the health
|
|
/// assertion are what tell the layers apart. Each test skips when its family's simulator is unreachable.
|
|
/// </remarks>
|
|
[Collection(AbLegacyServerCollection.Name)]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Simulator", "ab_server-PCCC")]
|
|
public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim)
|
|
{
|
|
private AbLegacyDriver NativeDriver(AbLegacyServerProfile profile, string id, params AbLegacyTagDefinition[] tags)
|
|
{
|
|
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
|
return new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
|
|
RawTags = AbLegacyRawTags.Entries(tags.Select(t => t with { DeviceHostAddress = deviceUri }).ToArray()),
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
Transport = AbLegacyTransport.Native,
|
|
}, driverInstanceId: id);
|
|
}
|
|
|
|
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type, bool writable = false) =>
|
|
new(Name: name, DeviceHostAddress: "", Address: address, DataType: type, Writable: writable);
|
|
|
|
/// <summary>A native PLC-5 non-N read is refused locally; an N read on the same driver still works.</summary>
|
|
[AbLegacyFact]
|
|
public async Task Native_Plc5_non_N_read_is_BadNotSupported_and_leaves_health_Healthy()
|
|
{
|
|
if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip);
|
|
await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-read",
|
|
Tag("Float", "F8:0", AbLegacyDataType.Float),
|
|
Tag("Int", "N7:0", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
// Positive control first: the connection and the handle path work, so the refusal below is per-tag.
|
|
var control = await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken);
|
|
control.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "PLC-5 N-file read is the verified path");
|
|
|
|
var refused = await drv.ReadAsync(["Float"], TestContext.Current.CancellationToken);
|
|
refused.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a driver-side refusal is not a comms fault");
|
|
|
|
// The refusal kept the connection: the N read still succeeds afterwards.
|
|
(await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken)).Single().StatusCode
|
|
.ShouldBe(AbLegacyStatusMapper.Good);
|
|
}
|
|
|
|
/// <summary>A native PLC-5 write is refused locally before anything reaches the device.</summary>
|
|
[AbLegacyFact]
|
|
public async Task Native_Plc5_write_is_BadNotSupported_and_leaves_health_Healthy()
|
|
{
|
|
if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip);
|
|
IntegrationWriteGate.SkipUnlessWritesAllowed(); // the write is refused, but never aim a write test at an unvetted endpoint
|
|
await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-write",
|
|
Tag("Scratch", "N7:5", AbLegacyDataType.Int, writable: true));
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var results = await drv.WriteAsync([new WriteRequest("Scratch", (short)0x1234)], TestContext.Current.CancellationToken);
|
|
|
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
|
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
|
drv.GetHealth().State.ShouldNotBe(DriverState.Faulted);
|
|
}
|
|
|
|
/// <summary>A native status-bit sub-element write is refused locally on the families that carry T/C/R files.</summary>
|
|
/// <remarks>
|
|
/// ⚠️ <b>The fixture has NO T/C/R files</b> (the SLC500 ab_server serves N7/F8/B3/L19 only), and that is what
|
|
/// makes this test discriminating: the write is FIRST-TOUCH (nothing reads the status-bit tag beforehand),
|
|
/// so a driver that created and initialised the tag's runtime before refusing would issue an init READ of a
|
|
/// missing file and report <c>BadCommunicationError</c> + Degraded health — exactly how this test first
|
|
/// failed (#593 follow-up). It passes only if the driver refuses before any wire request for the tag.
|
|
/// </remarks>
|
|
/// <param name="address">The status-bit address.</param>
|
|
[AbLegacyTheory]
|
|
[InlineData("T4:0.DN")]
|
|
[InlineData("C5:0.CU")]
|
|
[InlineData("R6:0.EN")]
|
|
public async Task Native_status_bit_write_is_BadNotSupported_and_leaves_health_Healthy(string address)
|
|
{
|
|
if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
|
|
IntegrationWriteGate.SkipUnlessWritesAllowed();
|
|
await using var drv = NativeDriver(KnownProfiles.Slc500, $"ablegacy-statusbit-{address}",
|
|
Tag("Scratch", "N7:5", AbLegacyDataType.Int),
|
|
Tag("Bit", address, AbLegacyDataType.Bit, writable: true));
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
// Positive control: the connection is live, so a BadNotSupported below is the driver's refusal.
|
|
(await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode
|
|
.ShouldBe(AbLegacyStatusMapper.Good);
|
|
|
|
var results = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken);
|
|
|
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
// The refusal left the connection usable.
|
|
(await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode
|
|
.ShouldBe(AbLegacyStatusMapper.Good);
|
|
}
|
|
}
|