e3cb4f0c7b
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
114 lines
5.6 KiB
C#
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);
|
|
}
|
|
}
|