284 lines
11 KiB
C#
284 lines
11 KiB
C#
using System.Text.Json;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// PR 9 — per-device <c>Timeout</c> + <c>Retries</c> overrides. SLC 5/01 needs ~5 s,
|
|
/// SLC 5/05 ~2 s, MicroLogix 1100 ~3 s — a single driver-wide timeout always misfires on
|
|
/// at least one chassis. Verifies the precedence rules (device > driver-wide > default),
|
|
/// that the resolved timeout flows into <see cref="AbLegacyTagCreateParams.Timeout"/>, and
|
|
/// that the retry loop honours the per-device count.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbLegacyPerDeviceTimeoutTests
|
|
{
|
|
private const string Host = "ab://10.0.0.5/1,0";
|
|
|
|
[Fact]
|
|
public async Task Per_device_Timeout_flows_into_AbLegacyTagCreateParams()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
// Driver-wide default 2 s — the device override below should win.
|
|
Timeout = TimeSpan.FromSeconds(2),
|
|
Devices =
|
|
[
|
|
new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500,
|
|
DeviceName: "slc-501",
|
|
Timeout: TimeSpan.FromSeconds(5)),
|
|
],
|
|
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Absent_per_device_Timeout_falls_back_to_driver_wide()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Timeout = TimeSpan.FromSeconds(7),
|
|
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
|
|
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Two_devices_each_use_their_own_Timeout_override()
|
|
{
|
|
const string fastHost = "ab://10.0.0.5/1,0";
|
|
const string slowHost = "ab://10.0.0.6/";
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Timeout = TimeSpan.FromSeconds(2),
|
|
Devices =
|
|
[
|
|
new AbLegacyDeviceOptions(fastHost, AbLegacyPlcFamily.Slc500,
|
|
Timeout: TimeSpan.FromMilliseconds(500)),
|
|
new AbLegacyDeviceOptions(slowHost, AbLegacyPlcFamily.MicroLogix,
|
|
Timeout: TimeSpan.FromSeconds(5)),
|
|
],
|
|
Tags =
|
|
[
|
|
new AbLegacyTagDefinition("Fast", fastHost, "N7:0", AbLegacyDataType.Int),
|
|
new AbLegacyTagDefinition("Slow", slowHost, "N7:1", AbLegacyDataType.Int),
|
|
],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["Fast", "Slow"], CancellationToken.None);
|
|
|
|
factory.Tags["N7:0"].CreationParams.Timeout.ShouldBe(TimeSpan.FromMilliseconds(500));
|
|
factory.Tags["N7:1"].CreationParams.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Per_device_Retries_2_yields_3_attempts_before_failure()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
factory.Customise = p => new FakeAbLegacyTag(p)
|
|
{
|
|
ThrowOnRead = true,
|
|
Exception = new TimeoutException("simulated transient"),
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices =
|
|
[
|
|
new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 2),
|
|
],
|
|
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
|
// 1 initial + 2 retries = 3 attempts.
|
|
factory.Tags["N7:0"].ReadCount.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task No_Retries_yields_single_attempt()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
factory.Customise = p => new FakeAbLegacyTag(p)
|
|
{
|
|
ThrowOnRead = true,
|
|
Exception = new TimeoutException("simulated transient"),
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
// Both null — defaults to 0 retries (single attempt).
|
|
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
|
|
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Driver_wide_Retries_applies_when_device_omits_override()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
factory.Customise = p => new FakeAbLegacyTag(p)
|
|
{
|
|
ThrowOnRead = true,
|
|
Exception = new TimeoutException("simulated transient"),
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Retries = 1, // driver-wide → 2 attempts total
|
|
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
|
|
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Per_device_Retries_overrides_driver_wide_default()
|
|
{
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
factory.Customise = p => new FakeAbLegacyTag(p)
|
|
{
|
|
ThrowOnRead = true,
|
|
Exception = new TimeoutException("simulated transient"),
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Retries = 5, // driver-wide
|
|
// Per-device says zero retries — should win, single attempt.
|
|
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 0)],
|
|
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Successful_read_after_one_transient_does_not_burn_remaining_retries()
|
|
{
|
|
// Verifies retries stop once the call succeeds — we shouldn't keep hammering.
|
|
var factory = new FakeAbLegacyTagFactory();
|
|
var attemptsBeforeSuccess = 1;
|
|
factory.Customise = p =>
|
|
{
|
|
var fake = new FlappyFake(p)
|
|
{
|
|
FailFirstN = attemptsBeforeSuccess,
|
|
FinalValue = 42,
|
|
};
|
|
return fake;
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Retries: 3)],
|
|
Tags = [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
|
|
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
snapshots.Single().Value.ShouldBe(42);
|
|
// 1 throw + 1 success = 2 attempts (we should NOT use all 4).
|
|
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dto_round_trip_preserves_TimeoutMs_and_Retries_at_both_levels()
|
|
{
|
|
const string json = """
|
|
{
|
|
"TimeoutMs": 4000,
|
|
"Retries": 1,
|
|
"Devices": [
|
|
{
|
|
"HostAddress": "ab://10.0.0.5/1,0",
|
|
"PlcFamily": "Slc500",
|
|
"TimeoutMs": 5000,
|
|
"Retries": 2
|
|
}
|
|
],
|
|
"Probe": { "Enabled": false },
|
|
"Tags": [
|
|
{ "Name": "X", "DeviceHostAddress": "ab://10.0.0.5/1,0", "Address": "N7:0", "DataType": "Int" }
|
|
]
|
|
}
|
|
""";
|
|
|
|
// Use the static factory so we exercise the deserialisation path used in production.
|
|
var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-roundtrip", json);
|
|
await drv.InitializeAsync(json, CancellationToken.None);
|
|
|
|
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
|
|
|
|
state.Options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(5000));
|
|
state.Options.Retries.ShouldBe(2);
|
|
|
|
// Per-device override wins over driver-wide.
|
|
drv.ResolveRetries(state).ShouldBe(2);
|
|
drv.ResolveTimeout(state).ShouldBe(TimeSpan.FromMilliseconds(5000));
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A fake that throws the first <c>FailFirstN</c> reads then succeeds. Used to assert
|
|
/// the retry loop stops once a call succeeds — it should not exhaust the retry budget.
|
|
/// </summary>
|
|
private sealed class FlappyFake : FakeAbLegacyTag
|
|
{
|
|
public int FailFirstN { get; set; }
|
|
public object? FinalValue { get; set; }
|
|
private int _calls;
|
|
public FlappyFake(AbLegacyTagCreateParams p) : base(p) { }
|
|
|
|
public override Task ReadAsync(CancellationToken ct)
|
|
{
|
|
_calls++;
|
|
// Increment ReadCount via the base accessor (it does its own increment + throw
|
|
// bookkeeping). Toggle ThrowOnRead based on the call number so the base helper does
|
|
// the throw for us.
|
|
if (_calls <= FailFirstN)
|
|
{
|
|
ThrowOnRead = true;
|
|
Exception = new TimeoutException("flap");
|
|
}
|
|
else
|
|
{
|
|
ThrowOnRead = false;
|
|
Value = FinalValue;
|
|
}
|
|
return base.ReadAsync(ct);
|
|
}
|
|
}
|
|
}
|