Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFactoryConfigTests.cs
T
Joseph Doherty a5a0f96d49
v2-ci / build (pull_request) Successful in 4m5s
v2-ci / unit-tests (pull_request) Failing after 10m45s
fix(drivers): harden operator TimeoutMs handling + AbLegacy evict parity
Follow-ups from the fleet-wide read-timeout audit that the S7 R2-01 read-leg
fix (PR #453) prompted. The audit confirmed S7 was the ONLY driver with the
async-read-ignores-socket-timeout hang; these are the two adjacent (non-hang)
findings it surfaced.

1. FOCAS TimeoutMs:0 footgun (the risky one): a non-positive Timeout made
   SynchronizedFocasClient DISABLE its per-call wall-clock ceiling, reverting to
   the caller's long-lived poll token — reintroducing exactly the frozen-peer
   wedge S7 just eliminated, under misconfig. Clamp non-positive TimeoutMs to the
   2s default at the config boundary so the deadline can never be authored away.

2. TimeoutMs validation symmetry: apply the same clamp in the AbCip + AbLegacy
   factories. libplctag's Tag.Timeout setter throws on <=0, faulting tag creation
   on every read/write; clamping keeps a misconfigured TimeoutMs:0 running on the
   default bound instead. (Shared PositiveTimeoutOrDefault helper per factory.)

3. AbLegacy reconnect parity with AbCip: AbLegacy evicted the cached libplctag
   runtime on neither the non-zero-status nor transport-exception read/write path
   (AbCip evicts on both), so a data-path fault recovered only via the probe loop
   / libplctag internals. Added EvictRuntime + wired it into both read and write
   failure paths so a fresh handle is created on the next call.

Tests: FOCAS 265->269 (clamp theory + positive), AbCip 336->339 (clamp theory +
positive), AbLegacy 209->212 (read-nonzero / read-exception / write-nonzero evict).
No production regressions; all three driver suites green.
2026-07-15 07:35:17 -04:00

248 lines
10 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Regression coverage for the FOCAS driver factory and fixed-tree capability
/// bootstrap — Driver.FOCAS-001 (config sections dropped) and Driver.FOCAS-002
/// (false-positive ProgramInfo capability).
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasFactoryConfigTests
{
// ---- Driver.FOCAS-001: FixedTree / AlarmProjection / HandleRecycle config sections ----
/// <summary>Verifies that the FixedTree configuration section is mapped to driver options.</summary>
[Fact]
public void CreateInstance_maps_FixedTree_section_onto_options()
{
const string json = """
{
"Backend": "unimplemented",
"FixedTree": {
"Enabled": true,
"PollInterval": "00:00:00.250",
"ProgramPollInterval": "00:00:01",
"TimerPollInterval": "00:00:30"
}
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.FixedTree.Enabled.ShouldBeTrue();
drv.Options.FixedTree.PollInterval.ShouldBe(TimeSpan.FromMilliseconds(250));
drv.Options.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(1));
drv.Options.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(30));
}
/// <summary>
/// The AdminUI persists FocasCncSeries as its integer value (e.g. <c>"series":6</c> = Thirty_i) —
/// a bare JSON number. The factory must tolerate it (via FlexibleStringConverter) and build the
/// real driver, not throw + fall back to a stub. Regression for the 2026-06-26 wonder data-plane
/// deploy where the driver stubbed on "Cannot get the value of a token type 'Number' as a string".
/// </summary>
[Fact]
public void CreateInstance_accepts_numeric_Series_from_AdminUI_serialization()
{
const string json = """
{"Backend":"wire","series":6,"devices":[{"hostAddress":"10.0.0.5:8193","deviceName":"Makino","series":6,"positionDecimalPlaces":0}]}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.Devices.ShouldHaveSingleItem();
drv.Options.Devices[0].Series.ShouldBe(FocasCncSeries.Thirty_i);
}
// ---- Driver timeout hardening: a non-positive TimeoutMs must not disable the per-call ceiling ----
/// <summary>
/// A misconfigured <c>TimeoutMs: 0</c> (or negative) must clamp to the 2 s default, NOT map to
/// <see cref="TimeSpan.Zero"/> — a zero/negative <c>Timeout</c> makes <c>SynchronizedFocasClient</c>
/// disable its per-call wall-clock ceiling, reintroducing the unbounded-read-on-a-frozen-peer
/// wedge the S7 R2-01 read-leg fix eliminated. Guards the factory clamp.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-5000)]
public void CreateInstance_clamps_non_positive_TimeoutMs_to_the_default(int timeoutMs)
{
var json = $$$"""{"Backend":"unimplemented","TimeoutMs":{{{timeoutMs}}},"Probe":{"TimeoutMs":{{{timeoutMs}}}}}""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(2_000));
drv.Options.Timeout.ShouldBeGreaterThan(TimeSpan.Zero);
drv.Options.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(2_000));
}
/// <summary>A positive <c>TimeoutMs</c> is honoured verbatim (the clamp only rewrites non-positive values).</summary>
[Fact]
public void CreateInstance_honours_a_positive_TimeoutMs()
{
const string json = """{"Backend":"unimplemented","TimeoutMs":750}""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(750));
}
/// <summary>Verifies that the AlarmProjection configuration section is mapped to driver options.</summary>
[Fact]
public void CreateInstance_maps_AlarmProjection_section_onto_options()
{
const string json = """
{
"Backend": "unimplemented",
"AlarmProjection": { "Enabled": true, "PollInterval": "00:00:02" }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.AlarmProjection.Enabled.ShouldBeTrue();
drv.Options.AlarmProjection.PollInterval.ShouldBe(TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that the HandleRecycle configuration section is mapped to driver options.</summary>
[Fact]
public void CreateInstance_maps_HandleRecycle_section_onto_options()
{
const string json = """
{
"Backend": "unimplemented",
"HandleRecycle": { "Enabled": true, "Interval": "01:00:00" }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.HandleRecycle.Enabled.ShouldBeTrue();
drv.Options.HandleRecycle.Interval.ShouldBe(TimeSpan.FromHours(1));
}
/// <summary>Verifies that all three optional configuration sections are mapped together.</summary>
[Fact]
public void CreateInstance_round_trips_all_three_opt_in_sections_together()
{
const string json = """
{
"Backend": "unimplemented",
"FixedTree": { "Enabled": true },
"AlarmProjection": { "Enabled": true },
"HandleRecycle": { "Enabled": true }
}
""";
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
drv.Options.FixedTree.Enabled.ShouldBeTrue();
drv.Options.AlarmProjection.Enabled.ShouldBeTrue();
drv.Options.HandleRecycle.Enabled.ShouldBeTrue();
}
/// <summary>Verifies that default disabled values are maintained when sections are absent.</summary>
[Fact]
public void CreateInstance_keeps_disabled_defaults_when_sections_absent()
{
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", """{ "Backend": "unimplemented" }""");
drv.Options.FixedTree.Enabled.ShouldBeFalse();
drv.Options.AlarmProjection.Enabled.ShouldBeFalse();
drv.Options.HandleRecycle.Enabled.ShouldBeFalse();
}
/// <summary>Verifies that per-field defaults are maintained when only Enabled is supplied.</summary>
[Fact]
public void CreateInstance_keeps_per_field_defaults_when_only_Enabled_supplied()
{
var defaults = new FocasFixedTreeOptions();
var drv = FocasDriverFactoryExtensions.CreateInstance(
"drv-1", """{ "Backend": "unimplemented", "FixedTree": { "Enabled": true } }""");
drv.Options.FixedTree.PollInterval.ShouldBe(defaults.PollInterval);
drv.Options.FixedTree.ProgramPollInterval.ShouldBe(defaults.ProgramPollInterval);
drv.Options.FixedTree.TimerPollInterval.ShouldBe(defaults.TimerPollInterval);
}
// ---- Driver.FOCAS-002: fixed-tree bootstrap must not declare a false ProgramInfo capability ----
/// <summary>Verifies that ProgramInfo is marked unsupported when probe throws.</summary>
[Fact]
public async Task FixedTree_bootstrap_marks_ProgramInfo_unsupported_when_probe_throws()
{
// A CNC series without cnc_exeprgname2 / cnc_rdopmode now surfaces as a thrown
// exception from GetProgramInfoAsync — SafeTryProbe must classify it as unsupported.
var factory = new FakeFocasClientFactory
{
Customise = () => new ProgramInfoFailingFocasClient(),
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(
() => drv.GetDeviceState("focas://10.0.0.5:8193")?.FixedTreeCache is not null,
TimeSpan.FromSeconds(2));
var caps = drv.GetDeviceState("focas://10.0.0.5:8193")!.FixedTreeCache!.Capabilities;
caps.ProgramInfo.ShouldBeFalse();
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that ProgramInfo is marked supported when probe succeeds.</summary>
[Fact]
public async Task FixedTree_bootstrap_marks_ProgramInfo_supported_when_probe_succeeds()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(
() => drv.GetDeviceState("focas://10.0.0.5:8193")?.FixedTreeCache is not null,
TimeSpan.FromSeconds(2));
var caps = drv.GetDeviceState("focas://10.0.0.5:8193")!.FixedTreeCache!.Capabilities;
caps.ProgramInfo.ShouldBeTrue();
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- helpers ----
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
/// <summary>
/// Fake whose <see cref="GetProgramInfoAsync"/> throws — mirrors the fixed
/// <see cref="Wire.WireFocasClient.GetProgramInfoAsync"/> behaviour on a series
/// that answers EW_FUNC / EW_NOOPT for cnc_exeprgname2 / cnc_rdopmode.
/// </summary>
private sealed class ProgramInfoFailingFocasClient : FakeFocasClient
{
/// <summary>Gets program info by throwing to simulate unsupported operations.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
/// <inheritdoc />
public override Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
throw new InvalidOperationException(
"cnc_exeprgname2 failed EW_6 and cnc_rdopmode failed EW_6.");
}
}