Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs
T
Joseph Doherty e3cb4f0c7b fix(ablegacy): opaque replies are neither answers nor faults; round-2 minors
Review r1 N1 (Important): the I1 fix made IsDeviceRefusal default-open and
mapped libplctag's ERR_REMOTE_ERR to an answer. libplctag folds EVERY non-zero
PCCC STS and CIP status into that code, including a routing hop's "unconnected
send timed out" / "connection lost" / "remote node missing", and native
BadReply covers truncated / mismatched / unmapped replies - so a powered-off
PLC behind a bridge read as reachable: Healthy, LastSuccessfulRead advancing,
the comms-loss watchdog unable to fire.

Three classes now, each pinned both ways:
- no reply (Timeout/BadConnection/Abort/Pending/unknown): comms fault;
- answered - an ALLOW-LIST again (NotFound/NotAllowed/OutOfBounds/TooLarge/
  TooSmall/TypeMismatch/DeviceStateConflict): proves reachability;
- OPAQUE (BadReply/Partial; ERR_REMOTE_ERR/ERR_BAD_STATUS now translate to the
  existing BadReply, OPC UA BadCommunicationError as before r0): per-tag Bad,
  handle kept, coalescing fallback, never fail-fast, never an answer. Option
  (a): a device whose pass/call produced only opaque outcomes is Degraded with
  LastSuccessfulRead not advanced, so the watchdog reaches a dead routed PLC.
  The probe and index reads classify the same way.

Minors:
- N2: a mismatched array (type vs data file) READS BadTypeMismatch instead of
  decoding the file at the wrong width as Good.
- N3: a swept indirect resolution is never reconnected under a forgotten gate;
  that one call answers neutrally.
- N4: an index read answered Unsupported is neutral, not a transport fault.
- N5: ArrayFileMismatch checks indirect addresses (the file letter is static).
- Test nit: the evicted-while-waiting test signals gate arrival via a seam
  instead of sleeping.

Live: 51/51 on ab_server (slc500/micrologix/plc5 x both transports), incl. an
opaque-only libplctag device staying Degraded with LastSuccessfulRead frozen.

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

114 lines
5.6 KiB
C#

using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyHostAndStatusTests
{
/// <summary>Verifies that HostAddress parses valid URI strings correctly.</summary>
/// <param name="input">The raw URI string to parse.</param>
/// <param name="gateway">The expected gateway host.</param>
/// <param name="port">The expected port number.</param>
/// <param name="path">The expected CIP path component.</param>
[Theory]
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
[InlineData("ab://plc-slc.factory/1,2", "plc-slc.factory", 44818, "1,2")]
public void HostAddress_parses_valid(string input, string gateway, int port, string path)
{
var parsed = AbLegacyHostAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Gateway.ShouldBe(gateway);
parsed.Port.ShouldBe(port);
parsed.CipPath.ShouldBe(path);
}
/// <summary>Verifies that HostAddress rejects invalid URI strings.</summary>
/// <param name="input">The invalid or null URI string to test.</param>
[Theory]
[InlineData(null)]
[InlineData("http://10.0.0.5/1,0")]
[InlineData("ab://10.0.0.5")]
[InlineData("ab:///1,0")]
[InlineData("ab://10.0.0.5:0/1,0")]
public void HostAddress_rejects_invalid(string? input)
{
AbLegacyHostAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that HostAddress.ToString produces canonical URI format.</summary>
[Fact]
public void HostAddress_ToString_canonicalises()
{
new AbLegacyHostAddress("10.0.0.5", 44818, "1,0").ToString().ShouldBe("ab://10.0.0.5/1,0");
new AbLegacyHostAddress("10.0.0.5", 2222, "1,0").ToString().ShouldBe("ab://10.0.0.5:2222/1,0");
}
/// <summary>Verifies that AbStatus members map to OPC UA status codes correctly.</summary>
/// <param name="status">The AbStatus value to map.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData(AbStatus.Ok, AbLegacyStatusMapper.Good)]
[InlineData(AbStatus.Pending, AbLegacyStatusMapper.GoodMoreData)]
[InlineData(AbStatus.Timeout, AbLegacyStatusMapper.BadTimeout)]
[InlineData(AbStatus.NotFound, AbLegacyStatusMapper.BadNodeIdUnknown)]
[InlineData(AbStatus.NotAllowed, AbLegacyStatusMapper.BadNotWritable)]
[InlineData(AbStatus.OutOfBounds, AbLegacyStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooLarge, AbLegacyStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.TooSmall, AbLegacyStatusMapper.BadOutOfRange)]
[InlineData(AbStatus.Unsupported, AbLegacyStatusMapper.BadNotSupported)]
[InlineData(AbStatus.TypeMismatch, AbLegacyStatusMapper.BadTypeMismatch)]
[InlineData(AbStatus.DeviceStateConflict, AbLegacyStatusMapper.BadDeviceFailure)]
[InlineData(AbStatus.BadConnection, AbLegacyStatusMapper.BadCommunicationError)]
[InlineData(AbStatus.BadReply, AbLegacyStatusMapper.BadCommunicationError)]
[InlineData(AbStatus.Partial, AbLegacyStatusMapper.BadCommunicationError)]
[InlineData(AbStatus.Abort, AbLegacyStatusMapper.BadCommunicationError)]
public void AbStatus_maps_to_opcua_codes(AbStatus status, uint expected)
{
AbLegacyStatusMapper.MapStatus(status).ShouldBe(expected);
// The int overload must agree — it is the seam IAbLegacyTagRuntime.GetStatus() drives.
AbLegacyStatusMapper.MapStatus((int)status).ShouldBe(expected);
}
/// <summary>
/// The regression oracle: the OPC UA code the pre-seam <c>MapLibplctagStatus(Status)</c> returned,
/// copied verbatim from the deleted switch. For every libplctag member the new
/// translation + <see cref="AbLegacyStatusMapper.MapStatus(AbStatus)"/> must reproduce it.
/// </summary>
private static uint OldMapLibplctagStatus(Status status) => status switch
{
Status.Ok => AbLegacyStatusMapper.Good,
Status.Pending => AbLegacyStatusMapper.GoodMoreData,
Status.ErrorTimeout => AbLegacyStatusMapper.BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbLegacyStatusMapper.BadNodeIdUnknown,
Status.ErrorNotAllowed => AbLegacyStatusMapper.BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbLegacyStatusMapper.BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbLegacyStatusMapper.BadNotSupported,
_ => AbLegacyStatusMapper.BadCommunicationError,
};
/// <summary>Every libplctag Status member maps to the same OPC UA code as before the seam.</summary>
[Fact]
public void Translation_plus_mapper_matches_the_old_libplctag_switch_for_every_member()
{
foreach (var s in Enum.GetValues<Status>())
{
AbLegacyStatusMapper.MapStatus(LibplctagLegacyStatusTranslation.ToAbStatus(s))
.ShouldBe(OldMapLibplctagStatus(s), $"libplctag Status.{s}");
}
}
/// <summary>Verifies that timeout errors are distinguished from generic communication errors.</summary>
[Fact]
public void MapStatus_distinguishes_timeout_from_generic_comms_error()
{
AbLegacyStatusMapper.MapStatus((int)AbStatus.Timeout).ShouldBe(AbLegacyStatusMapper.BadTimeout);
AbLegacyStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
}
}