235b8b8e6d
Equipment tags were stuck at Bad_WaitingForInitialData on the deployed driver: the equipment poll, fixed-tree loop, probe and recycle shared one FOCAS/2 socket with no serialization, and the steady-state read had no timeout — concurrent reads collided and a stalled read hung forever, never overwriting the node's initial-data seed.
- SynchronizedFocasClient: per-device SemaphoreSlim gate + per-call timeout around every wire op (Connect/Probe gated, not double-bounded); wired in EnsureConnectedAsync. ReadAsync/WriteAsync map a per-call timeout to BadCommunicationError instead of rethrowing.
- FlexibleStringConverter on FOCAS config Series: the AdminUI persists the enum as a number ("series":6); accept number-or-string instead of throwing -> stub.
- FocasHostAddress.TryParse tolerates a scheme-less {ip}[:{port}] (AdminUI hostAddress form); canonical focas:// unchanged, malformed schemes still rejected.
247 FOCAS tests green; each fix has a regression test. Live-validated on wonder-app-vd03 (tags read Good).
214 lines
8.7 KiB
C#
214 lines
8.7 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);
|
|
}
|
|
|
|
/// <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.");
|
|
}
|
|
}
|