Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATConnectBackoffTests.cs
T
Joseph Doherty a53be2af65 v3(b1-twincat): apply Modbus exemplar pattern to TwinCAT (multi-device)
- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
  deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
  Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  _options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
  Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
  (by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
  Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.

Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
2026-07-15 20:11:23 -04:00

91 lines
3.8 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// 05/STAB-8 — a dead TwinCAT device must not be hammered with a full connect attempt on every
/// data call. The per-device <see cref="ConnectionBackoff"/> gates the connect/create path;
/// the probe loop bypasses it (its own interval is the recovery cadence) and a successful
/// connect resets the window instantly so recovery is never delayed. No TC3 fixture exists —
/// the fake-client suite is authoritative.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATConnectBackoffTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = probeEnabled,
Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromMilliseconds(250),
},
EnableControllerBrowse = false,
};
/// <summary>Rapid data calls against a dead device attempt a single connect, then fail fast inside the backoff window.</summary>
[Fact]
public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient
{
ThrowOnConnect = true,
Exception = new InvalidOperationException("device down"),
},
};
var drv = new TwinCATDriver(Options(probeEnabled: false), "twincat-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
for (var i = 0; i < 6; i++)
await drv.ReadAsync(["T"], CancellationToken.None);
// One real connect attempt; the remaining five fail fast inside the 1 s window — no new
// client is created (the fake factory records one client per attempted connect).
factory.Clients.Count.ShouldBe(1);
}
/// <summary>The probe bypasses the backoff window and a successful connect resets it so the next data call succeeds immediately.</summary>
[Fact]
public async Task ProbeBypassesBackoff_AndSuccessResets()
{
var fail = true;
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient
{
ThrowOnConnect = fail,
Exception = new InvalidOperationException("device down"),
},
};
var drv = new TwinCATDriver(Options(probeEnabled: true), "twincat-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// First data call opens the backoff window on a dead device.
var down = await drv.ReadAsync(["T"], CancellationToken.None);
down[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
// Device recovers. The data path is still inside the window, but the probe bypasses it and
// reconnects — resetting the window 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(["T"], CancellationToken.None);
if (r[0].StatusCode == TwinCATStatusMapper.Good) { good = r[0]; break; }
await Task.Delay(25, CancellationToken.None);
}
good.ShouldNotBeNull("probe-driven reconnect must reset the backoff so data reads recover");
}
}