using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
/// 05/STAB-8 — a dead FOCAS CNC must not pay a full two-socket reconnect on every tick of each
/// read / probe / fixed-tree / recycle loop. The per-device
/// gates the connect path; the probe bypasses it and a successful connect resets it. No FOCAS
/// fixture exists — the fake-client suite is authoritative.
///
[Trait("Category", "Unit")]
public sealed class FocasConnectBackoffTests
{
private static FocasDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
Probe = new FocasProbeOptions
{
Enabled = probeEnabled,
Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromMilliseconds(250),
},
// FixedTree / HandleRecycle / AlarmProjection default off — keep only the data path (+ probe).
};
/// Rapid data calls against a dead CNC attempt a single connect, then fail fast inside the backoff window.
[Fact]
public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient
{
ThrowOnConnect = true,
Exception = new InvalidOperationException("cnc down"),
},
};
var drv = new FocasDriver(Options(probeEnabled: false), "focas-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
for (var i = 0; i < 6; i++)
await drv.ReadAsync(["R"], CancellationToken.None);
// One real connect attempt; the remaining five fail fast inside the window — no new socket
// is created (the fake factory records one client per attempted connect).
factory.Clients.Count.ShouldBe(1);
}
/// The probe bypasses the backoff window and a successful connect resets it so the next data call succeeds immediately.
[Fact]
public async Task ProbeBypassesBackoff_AndSuccessResets()
{
var fail = true;
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient
{
ThrowOnConnect = fail,
Exception = new InvalidOperationException("cnc down"),
},
};
var drv = new FocasDriver(Options(probeEnabled: true), "focas-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var down = await drv.ReadAsync(["R"], CancellationToken.None);
down[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
// Device recovers. The probe bypasses the still-open window, reconnects, and resets it so
// the following data read succeeds with no residual delay.
fail = false;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
DataValueSnapshot? good = null;
while (DateTime.UtcNow < deadline)
{
var r = await drv.ReadAsync(["R"], CancellationToken.None);
if (r[0].StatusCode == FocasStatusMapper.Good) { good = r[0]; break; }
await Task.Delay(25, CancellationToken.None);
}
good.ShouldNotBeNull("probe-driven reconnect must reset the backoff so data reads recover");
}
}