Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFactoryConfigTests.cs
Joseph Doherty c9e446387a fix(driver-focas): resolve High code-review findings (Driver.FOCAS-001, Driver.FOCAS-002)
Driver.FOCAS-001: FocasDriverConfigDto exposed no FixedTree / AlarmProjection /
HandleRecycle sections, so a deployment that opted into those features per
docs/drivers/FOCAS.md had the sections silently dropped by case-insensitive
JSON parsing and the features stayed at their disabled defaults. Added
FocasFixedTreeDto / FocasAlarmProjectionDto / FocasHandleRecycleDto and Build*
mappers in CreateInstance that populate the matching FocasDriverOptions
properties; a missing section or field keeps its existing default.

Driver.FOCAS-002: the fixed-tree bootstrap probe classified ProgramInfo as
"supported" whenever GetProgramInfoAsync returned non-null, but WireFocasClient
.GetProgramInfoAsync substituted defaults instead of throwing on a FOCAS error
return, so a CNC series answering EW_FUNC/EW_NOOPT for cnc_exeprgname2 /
cnc_rdopmode still got the Program/ and OperationMode/ subtrees. The method now
throws InvalidOperationException when neither the program-name nor the op-mode
read is IsOk, so SafeTryProbe correctly suppresses the capability.

Added FocasFactoryConfigTests covering the three opt-in config sections
round-tripping through CreateInstance and the fixed-tree bootstrap classifying
ProgramInfo as unsupported when the probe throws. Added an internal
FocasDriver.Options test seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:41:28 -04:00

183 lines
6.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 ----
[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));
}
[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));
}
[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));
}
[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();
}
[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();
}
[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 ----
[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);
}
[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
{
public override Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
throw new InvalidOperationException(
"cnc_exeprgname2 failed EW_6 and cnc_rdopmode failed EW_6.");
}
}