refactor(abcip): confine libplctag to its adapters behind AbStatus/AbTransportException
This commit is contained in:
+2
-2
@@ -1,4 +1,4 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -323,7 +323,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
|
||||
// First tag creation → returns error; second → returns success.
|
||||
callCount++;
|
||||
return callCount == 1
|
||||
? new FakeAbCipTag(p) { Status = (int)libplctag.Status.ErrorBadConnection }
|
||||
? new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }
|
||||
: new FakeAbCipTag(p) { Status = 0, Value = 42 };
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -151,27 +151,22 @@ public sealed class AbCipDriverProbeTests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The exception path classifies by the SAME rule as the status path. libplctag.NET builds
|
||||
/// <see cref="LibPlcTagException"/> with <c>Status.ToString()</c>, so its message is
|
||||
/// "ErrorNotFound" — this method used to look for the native C spelling "STATUS_NOT_FOUND",
|
||||
/// which that message can never contain, so a controller that answered "tag does not exist"
|
||||
/// was reported as a failed handshake. That is the answer every Test Connect gets on a device
|
||||
/// with no authored tags, i.e. every device at the moment an operator first presses the button.
|
||||
/// The exception path classifies by the SAME rule as the status path: a controller that answered
|
||||
/// "tag does not exist" is reachable (the answer every Test Connect gets on a device with no
|
||||
/// authored tags); a session or transport failure is not.
|
||||
/// </summary>
|
||||
/// <param name="status">The status libplctag threw on.</param>
|
||||
/// <param name="status">The status the transport threw on.</param>
|
||||
/// <param name="expectedReachable">Whether the controller should be reported reachable.</param>
|
||||
[Theory]
|
||||
[InlineData(Status.ErrorNotFound, true)]
|
||||
[InlineData(Status.ErrorNoMatch, true)]
|
||||
[InlineData(Status.ErrorBadDevice, true)]
|
||||
[InlineData(Status.ErrorTimeout, false)]
|
||||
[InlineData(Status.ErrorBadConnection, false)]
|
||||
[InlineData(Status.ErrorBadGateway, false)]
|
||||
[InlineData(Status.ErrorBadParam, false)]
|
||||
public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(Status status, bool expectedReachable)
|
||||
[InlineData(AbStatus.NotFound, true)]
|
||||
[InlineData(AbStatus.Timeout, false)]
|
||||
[InlineData(AbStatus.BadConnection, false)]
|
||||
[InlineData(AbStatus.BadReply, false)]
|
||||
[InlineData(AbStatus.Partial, false)]
|
||||
[InlineData(AbStatus.Abort, false)]
|
||||
public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(AbStatus status, bool expectedReachable)
|
||||
{
|
||||
// Exactly how NativeTagWrapper.ThrowIfStatusNotOk constructs it.
|
||||
var ex = new LibPlcTagException(status.ToString());
|
||||
var ex = new AbTransportException(status, status.ToString());
|
||||
|
||||
AbCipDriverProbe.IsReachableException(ex).ShouldBe(expectedReachable);
|
||||
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(expectedReachable);
|
||||
@@ -185,5 +180,5 @@ public sealed class AbCipDriverProbeTests
|
||||
[Fact]
|
||||
public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable()
|
||||
=> AbCipDriverProbe.IsReachableException(
|
||||
new LibPlcTagException("something the wrapper has never said")).ShouldBeFalse();
|
||||
new AbTransportException(AbStatus.BadConnection, "something the wrapper has never said")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -87,14 +87,14 @@ public sealed class AbCipDriverReadTests
|
||||
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-zero libplctag status is mapped via AbCipStatusMapper.</summary>
|
||||
/// <summary>Verifies that non-zero transport status is mapped via AbCipStatusMapper.</summary>
|
||||
[Fact]
|
||||
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
|
||||
public async Task NonZero_transport_status_maps_via_AbCipStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorNotFound };
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
@@ -89,7 +90,7 @@ public sealed class AbCipDriverWholeUdtReadTests
|
||||
|
||||
// Prime runtime existence via a first (successful) read so we can flip it to error.
|
||||
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
|
||||
factory.Tags["Motor"].Status = (int)AbStatus.Timeout; // mapped in AbCipStatusMapper
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -84,14 +84,14 @@ public sealed class AbCipDriverWriteTests
|
||||
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-zero libplctag status after write maps correctly.</summary>
|
||||
/// <summary>Verifies that non-zero transport status after write maps correctly.</summary>
|
||||
[Fact]
|
||||
public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
|
||||
public async Task Non_zero_transport_status_after_write_maps_via_AbCipStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorTimeout };
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.Timeout };
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Broken", 1)], CancellationToken.None);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -88,7 +89,7 @@ public sealed class AbCipLoggingTests
|
||||
var logger = new CapturingLogger<AbCipDriver>();
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbCipTag(p) { Status = (int)libplctag.Status.ErrorBadConnection },
|
||||
Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection },
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
@@ -29,41 +30,78 @@ public sealed class AbCipStatusMapperTests
|
||||
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// Driver.AbCip-002 — the integers here are the underlying values of the libplctag.NET
|
||||
// Status enum (what (int)Tag.GetStatus() actually returns), NOT raw native PLCTAG_ERR_*
|
||||
// constants. The libplctag.NET wrapper renumbers the native codes into a contiguous enum.
|
||||
/// <summary>Verifies that libplctag Status enum values are mapped to OPC UA status codes.</summary>
|
||||
/// <param name="status">The libplctag Status enum value to map.</param>
|
||||
/// <summary>Verifies that transport status values are mapped to OPC UA status codes.</summary>
|
||||
/// <param name="status">The transport status to map.</param>
|
||||
/// <param name="expected">The expected OPC UA status code.</param>
|
||||
[Theory]
|
||||
[InlineData(Status.Ok, AbCipStatusMapper.Good)]
|
||||
[InlineData(Status.Pending, AbCipStatusMapper.GoodMoreData)]
|
||||
[InlineData(Status.ErrorTimeout, AbCipStatusMapper.BadTimeout)]
|
||||
[InlineData(Status.ErrorNotFound, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(Status.ErrorNotAllowed, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData(Status.ErrorOutOfBounds, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(Status.ErrorBadConnection, AbCipStatusMapper.BadCommunicationError)]
|
||||
[InlineData(Status.ErrorBadGateway, AbCipStatusMapper.BadCommunicationError)]
|
||||
[InlineData(Status.ErrorUnsupported, AbCipStatusMapper.BadNotSupported)]
|
||||
[InlineData(Status.ErrorNoMem, AbCipStatusMapper.BadCommunicationError)] // unmapped → generic comms
|
||||
public void MapLibplctagStatus_maps_real_enum_members(Status status, uint expected)
|
||||
[InlineData(AbStatus.Ok, AbCipStatusMapper.Good)]
|
||||
[InlineData(AbStatus.Pending, AbCipStatusMapper.GoodMoreData)]
|
||||
[InlineData(AbStatus.Timeout, AbCipStatusMapper.BadTimeout)]
|
||||
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotWritable)]
|
||||
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.Unsupported, AbCipStatusMapper.BadNotSupported)]
|
||||
[InlineData(AbStatus.TypeMismatch, AbCipStatusMapper.BadTypeMismatch)]
|
||||
[InlineData(AbStatus.DeviceStateConflict, AbCipStatusMapper.BadDeviceFailure)]
|
||||
[InlineData(AbStatus.BadConnection, AbCipStatusMapper.BadCommunicationError)]
|
||||
[InlineData(AbStatus.BadReply, AbCipStatusMapper.BadCommunicationError)]
|
||||
[InlineData(AbStatus.Partial, AbCipStatusMapper.BadCommunicationError)]
|
||||
[InlineData(AbStatus.Abort, AbCipStatusMapper.BadCommunicationError)]
|
||||
public void MapStatus_maps_every_AbStatus(AbStatus status, uint expected)
|
||||
{
|
||||
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
|
||||
// The int overload must agree — it is the seam IAbCipTagRuntime.GetStatus() drives.
|
||||
AbCipStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
|
||||
AbCipStatusMapper.MapStatus(status).ShouldBe(expected);
|
||||
// The int overload must agree - it is the seam IAbCipTagRuntime.GetStatus() drives.
|
||||
AbCipStatusMapper.MapStatus((int)status).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that timeout is distinguished from generic communication error.</summary>
|
||||
[Fact]
|
||||
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
|
||||
public void MapStatus_distinguishes_timeout_from_generic_comms_error()
|
||||
{
|
||||
// Regression for Driver.AbCip-002: a real timeout used to fall through to
|
||||
// BadCommunicationError because the code matched the wrong integer (-5).
|
||||
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorTimeout)
|
||||
.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
|
||||
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound)
|
||||
.ShouldNotBe(AbCipStatusMapper.BadCommunicationError);
|
||||
AbCipStatusMapper.MapStatus((int)AbStatus.Timeout).ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
AbCipStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
AbCipStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldNotBe(AbCipStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies MapException maps transport exceptions by status and everything else to comms error.</summary>
|
||||
[Fact]
|
||||
public void MapException_uses_the_transport_status()
|
||||
{
|
||||
AbCipStatusMapper.MapException(new AbTransportException(AbStatus.NotFound, "x"))
|
||||
.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
AbCipStatusMapper.MapException(new InvalidOperationException("x"))
|
||||
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression oracle: the OPC UA code the pre-AbStatus <c>MapLibplctagStatus</c> returned for every
|
||||
/// libplctag member (table copied from the deleted switch) must equal
|
||||
/// <c>MapStatus(ToAbStatus(member))</c>.
|
||||
/// </summary>
|
||||
/// <param name="status">A libplctag status.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(AllLibplctagStatuses))]
|
||||
public void Translation_plus_MapStatus_matches_the_old_libplctag_mapping(Status status)
|
||||
{
|
||||
AbCipStatusMapper.MapStatus(LibplctagStatusTranslation.ToAbStatus(status)).ShouldBe(OldMapLibplctagStatus(status));
|
||||
}
|
||||
|
||||
/// <summary>Every defined libplctag <see cref="Status"/> member.</summary>
|
||||
/// <returns>One row per member.</returns>
|
||||
public static IEnumerable<object[]> AllLibplctagStatuses() =>
|
||||
Enum.GetValues<Status>().Select(s => new object[] { s });
|
||||
|
||||
private static uint OldMapLibplctagStatus(Status status) => status switch
|
||||
{
|
||||
Status.Ok => AbCipStatusMapper.Good,
|
||||
Status.Pending => AbCipStatusMapper.GoodMoreData,
|
||||
Status.ErrorTimeout => AbCipStatusMapper.BadTimeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown,
|
||||
Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable,
|
||||
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported,
|
||||
_ => AbCipStatusMapper.BadCommunicationError, // incl. BadConnection/BadGateway/BadReply/Partial/Abort/...
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
/// <summary>
|
||||
/// A tag the controller does not have is a configuration error, not a lost connection. Seen live
|
||||
/// against a production CompactLogix: with one misspelled tag first in the read list, libplctag's
|
||||
/// create threw <c>LibPlcTagException("ErrorNotFound")</c>, the driver counted that as a failed
|
||||
/// create threw <c>AbTransportException(AbStatus.NotFound)</c>, the driver counted that as a failed
|
||||
/// Forward-Open, opened the device's connect-backoff window, and every other not-yet-connected tag
|
||||
/// on the device — all of them real — came back <c>BadCommunicationError</c> (throttled). The bad
|
||||
/// tag itself also read <c>BadCommunicationError</c> instead of <c>BadNodeIdUnknown</c>.
|
||||
@@ -43,7 +43,7 @@ public sealed class AbCipUnknownTagReadTests
|
||||
[Fact]
|
||||
public async Task Unknown_tag_does_not_throttle_sibling_tags_and_reads_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorNotFound") : null);
|
||||
var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new AbTransportException(AbStatus.NotFound, "NotFound") : null);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["Typo", "Real1", "Real2"], TestContext.Current.CancellationToken);
|
||||
@@ -61,7 +61,7 @@ public sealed class AbCipUnknownTagReadTests
|
||||
[Fact]
|
||||
public async Task A_connection_failure_still_opens_the_backoff_window()
|
||||
{
|
||||
var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorTimeout") : null);
|
||||
var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new AbTransportException(AbStatus.Timeout, "Timeout") : null);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["Typo", "Real1"], TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using libplctag;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>Pins the libplctag <see cref="Status"/> to <see cref="AbStatus"/> translation table.</summary>
|
||||
public sealed class LibplctagStatusTranslationTests
|
||||
{
|
||||
/// <summary>Each libplctag status lands in the documented bucket.</summary>
|
||||
/// <param name="from">The libplctag status.</param>
|
||||
/// <param name="to">The expected transport status.</param>
|
||||
[Theory]
|
||||
[InlineData(Status.Ok, AbStatus.Ok)]
|
||||
[InlineData(Status.Pending, AbStatus.Pending)]
|
||||
[InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
|
||||
[InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
|
||||
[InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
|
||||
[InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
|
||||
[InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
|
||||
[InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)]
|
||||
[InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)]
|
||||
[InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)]
|
||||
[InlineData(Status.ErrorPartial, AbStatus.Partial)]
|
||||
[InlineData(Status.ErrorAbort, AbStatus.Abort)]
|
||||
[InlineData(Status.ErrorBadReply, AbStatus.BadReply)]
|
||||
[InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)]
|
||||
[InlineData(Status.ErrorWinsock, AbStatus.BadConnection)]
|
||||
public void Translates(Status from, AbStatus to) => LibplctagStatusTranslation.ToAbStatus(from).ShouldBe(to);
|
||||
|
||||
/// <summary>The exception message is recovered as the enum name.</summary>
|
||||
[Fact]
|
||||
public void Exception_message_is_recovered_as_the_enum_name()
|
||||
{
|
||||
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound);
|
||||
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
/// <summary>Wrap rethrows as a transport exception keeping the original as InnerException.</summary>
|
||||
[Fact]
|
||||
public async Task Wrap_rethrows_as_AbTransportException_with_inner()
|
||||
{
|
||||
var inner = new LibPlcTagException("ErrorTimeout");
|
||||
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(
|
||||
() => LibplctagStatusTranslation.Wrap(() => throw inner));
|
||||
|
||||
ex.Status.ShouldBe(AbStatus.Timeout);
|
||||
ex.InnerException.ShouldBeSameAs(inner);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user