8b2c3fa04c
- Rename FocasEquipmentTagParser -> FocasTagDefinitionFactory; TryParse ->
FromTagConfig(tagConfig, rawPath, out def). Drop leading-{ heuristic + the
deviceHostAddress blob key; def.Name = rawPath. Add inverse ToTagConfig.
writable: absent -> read-only, explicit true honoured (note preserved).
- FocasDriverOptions.Tags -> RawTags (IReadOnlyList<RawTagEntry>).
- FocasDriver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
RawTags via FromTagConfig; multi-device routing threads RawTagEntry.DeviceName
-> ResolveDeviceHost match against options.Devices -> def.DeviceHostAddress
(TODO v3 WaveC: live host from Device row DeviceConfig). Tolerant per-tag
skip+log (mapper/address/matrix miss -> BadNodeIdUnknown); unknown device
fails init fast.
- Factory: bind List<RawTagEntry> RawTags; retire FocasTagDto/ParseDataType.
- CLI FocasCommandBase.BuildOptions -> RawTags via ToTagConfig + DeviceName.
- Tests: FocasRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
FromTagConfig; rewrite retired blob-ref tests (equipment-tag/capability-gate/
resolve-host) onto authored RawPath. 272 driver + 52 CLI green.
87 lines
3.6 KiB
C#
87 lines
3.6 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="ConnectionBackoff"/>
|
|
/// gates the connect path; the probe bypasses it and a successful connect resets it. No FOCAS
|
|
/// fixture exists — the fake-client suite is authoritative.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasConnectBackoffTests
|
|
{
|
|
private static FocasDriverOptions Options(bool probeEnabled) => new()
|
|
{
|
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
|
RawTags = [FocasRawTags.Entry(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).
|
|
};
|
|
|
|
/// <summary>Rapid data calls against a dead CNC attempt a single connect, then fail fast inside the backoff window.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <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 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");
|
|
}
|
|
}
|