fix(driver-focas-cli): resolve Low code-review findings (Driver.FOCAS.Cli-001,002,003,004; -005 deferred)
- Driver.FOCAS.Cli-001: WriteCommand.ParseValue now wraps numeric FormatException / OverflowException as CliFx CommandException with the offending value. - Driver.FOCAS.Cli-002: SubscribeCommand's OnDataChange handler and the banner both take a writeLock so notification-callback and main-thread writes can't interleave; handler exceptions are warn-and-swallow. - Driver.FOCAS.Cli-003: FocasCommandBase.ValidateOptions rejects --cnc-port outside 1..65535, non-positive --timeout-ms, and non-positive --interval-ms; ExecuteAsync calls it first. - Driver.FOCAS.Cli-004: 'await using var driver' is the sole driver disposal path; dropped the redundant explicit await ShutdownAsync. - Driver.FOCAS.Cli-005 (Deferred): the fix lives in Driver.Cli.Common.SnapshotFormatter — explicitly naming the status-code shortlist there benefits every driver CLI. Left as a Driver.Cli.Common follow-up. - Registered the new tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests project in ZB.MOM.WW.OtOpcUa.slnx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.FOCAS.Cli-004: every FOCAS CLI command must own one disposal mechanism for
|
||||
/// the <c>FocasDriver</c>, not two. The chosen mechanism is <c>await using var driver
|
||||
/// = ...</c> — <c>FocasDriver.DisposeAsync</c> already calls <c>ShutdownAsync</c>, so
|
||||
/// an additional explicit <c>driver.ShutdownAsync(...)</c> in a <c>finally</c> block
|
||||
/// runs shutdown twice. These tests guard against that regression by scanning the
|
||||
/// command source files.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CommandDisposalConventionsTests
|
||||
{
|
||||
private static readonly string CommandsDir = LocateCommandsDir();
|
||||
|
||||
[Theory]
|
||||
[InlineData("ProbeCommand.cs")]
|
||||
[InlineData("ReadCommand.cs")]
|
||||
[InlineData("WriteCommand.cs")]
|
||||
[InlineData("SubscribeCommand.cs")]
|
||||
public void Command_does_not_call_ShutdownAsync_explicitly(string commandFile)
|
||||
{
|
||||
var path = Path.Combine(CommandsDir, commandFile);
|
||||
File.Exists(path).ShouldBeTrue($"Expected {path} to exist.");
|
||||
var source = File.ReadAllText(path);
|
||||
|
||||
// The await-using statement is the single disposal mechanism. An explicit
|
||||
// driver.ShutdownAsync(...) call (typically inside a finally block) re-invokes
|
||||
// a shutdown path that DisposeAsync already runs and is the smell -004 flags.
|
||||
source.ShouldNotContain("driver.ShutdownAsync(");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ProbeCommand.cs")]
|
||||
[InlineData("ReadCommand.cs")]
|
||||
[InlineData("WriteCommand.cs")]
|
||||
[InlineData("SubscribeCommand.cs")]
|
||||
public void Command_uses_await_using_for_FocasDriver(string commandFile)
|
||||
{
|
||||
var path = Path.Combine(CommandsDir, commandFile);
|
||||
var source = File.ReadAllText(path);
|
||||
|
||||
source.ShouldContain("await using var driver = new FocasDriver(");
|
||||
}
|
||||
|
||||
private static string LocateCommandsDir()
|
||||
{
|
||||
// Walk up from the test assembly bin/ folder to the repo root, then into the
|
||||
// source project's Commands/ directory. The test-host puts CWD somewhere under
|
||||
// bin/Debug/net10.0 so we resolve relative to AppContext.BaseDirectory.
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
dir = dir.Parent;
|
||||
dir.ShouldNotBeNull("Could not find solution root (ZB.MOM.WW.OtOpcUa.slnx).");
|
||||
return Path.Combine(
|
||||
dir!.FullName, "src", "Drivers", "Cli", "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli", "Commands");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="FocasCommandBase.BuildOptions"/> — the pure, deterministic mapping
|
||||
/// from the base's CNC host/port/series/timeout flags onto a
|
||||
/// <c>FocasDriverOptions</c>. The CLI is one-shot so the background connectivity probe
|
||||
/// must be disabled.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasCommandBaseBuildOptionsTests
|
||||
{
|
||||
[Command("noop-test", Description = "Test-only probe of FocasCommandBase.BuildOptions.")]
|
||||
private sealed class ProbeOnly : FocasCommandBase
|
||||
{
|
||||
public override ValueTask ExecuteAsync(IConsole console) => default;
|
||||
public FocasDriverOptions Invoke(IReadOnlyList<FocasTagDefinition> tags) => BuildOptions(tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOptions_disables_probe_for_one_shot_cli_runs()
|
||||
{
|
||||
var sut = new ProbeOnly
|
||||
{
|
||||
CncHost = "10.0.0.5",
|
||||
CncPort = 8193,
|
||||
Series = FocasCncSeries.ThirtyOne_i,
|
||||
TimeoutMs = 5000,
|
||||
};
|
||||
|
||||
var options = sut.Invoke([]);
|
||||
|
||||
options.Probe.ShouldNotBeNull();
|
||||
options.Probe.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
|
||||
{
|
||||
var sut = new ProbeOnly { CncHost = "h", TimeoutMs = 7500 };
|
||||
|
||||
var options = sut.Invoke([]);
|
||||
|
||||
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOptions_flows_host_port_series_through()
|
||||
{
|
||||
var sut = new ProbeOnly
|
||||
{
|
||||
CncHost = "cnc.shop.local",
|
||||
CncPort = 8194,
|
||||
Series = FocasCncSeries.Zero_i_F,
|
||||
TimeoutMs = 3000,
|
||||
};
|
||||
|
||||
var options = sut.Invoke([]);
|
||||
|
||||
options.Devices.Count.ShouldBe(1);
|
||||
options.Devices[0].HostAddress.ShouldBe("focas://cnc.shop.local:8194");
|
||||
options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOptions_forwards_tag_list_verbatim()
|
||||
{
|
||||
var sut = new ProbeOnly { CncHost = "h" };
|
||||
var tag = new FocasTagDefinition(
|
||||
Name: "t",
|
||||
DeviceHostAddress: "focas://h:8193",
|
||||
Address: "R100",
|
||||
DataType: FocasDataType.Int16,
|
||||
Writable: false);
|
||||
|
||||
var options = sut.Invoke([tag]);
|
||||
|
||||
options.Tags.Count.ShouldBe(1);
|
||||
options.Tags[0].ShouldBeSameAs(tag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.FOCAS.Cli-003: numeric options that are not range-checked at the CLI
|
||||
/// boundary surface either as opaque downstream exceptions or as tight-spinning
|
||||
/// poll loops rather than a clear "value must be positive" message. These tests
|
||||
/// pin the validation contract for <c>--cnc-port</c>, <c>--timeout-ms</c>, and
|
||||
/// <c>--interval-ms</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasCommandBaseValidationTests
|
||||
{
|
||||
// Test-only FocasCommandBase concrete subclass that exposes the protected ValidateOptions
|
||||
// helper. The [Command] attribute is required by the CliFx analyzer
|
||||
// (CliFx_CommandMustBeAnnotated) — this command is never registered with the CLI app
|
||||
// but the analyzer rule fires for every ICommand implementor in the compilation.
|
||||
[Command("noop-test", Description = "Test-only probe of FocasCommandBase.ValidateOptions.")]
|
||||
private sealed class Probe : FocasCommandBase
|
||||
{
|
||||
public int IntervalMs { get; init; }
|
||||
|
||||
public override ValueTask ExecuteAsync(IConsole console) => default;
|
||||
|
||||
public void InvokeValidate() => ValidateOptions(IntervalMs);
|
||||
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_accepts_default_options()
|
||||
{
|
||||
var sut = new Probe { CncHost = "host", IntervalMs = 1000 };
|
||||
Should.NotThrow(() => sut.InvokeValidate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(65536)]
|
||||
public void Validate_rejects_out_of_range_cnc_port(int port)
|
||||
{
|
||||
var sut = new Probe { CncHost = "host", CncPort = port, IntervalMs = 1000 };
|
||||
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
|
||||
ex.Message.ShouldContain("cnc-port", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-100)]
|
||||
public void Validate_rejects_non_positive_timeout_ms(int timeoutMs)
|
||||
{
|
||||
var sut = new Probe { CncHost = "host", TimeoutMs = timeoutMs, IntervalMs = 1000 };
|
||||
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
|
||||
ex.Message.ShouldContain("timeout-ms", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-500)]
|
||||
public void Validate_rejects_non_positive_interval_ms(int intervalMs)
|
||||
{
|
||||
var sut = new Probe { CncHost = "host", IntervalMs = intervalMs };
|
||||
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
|
||||
ex.Message.ShouldContain("interval-ms", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_skips_interval_check_when_command_omits_it()
|
||||
{
|
||||
// probe / read / write don't take an --interval-ms option; the validator must
|
||||
// skip that check when the caller passes null.
|
||||
var sut = new Probe { CncHost = "host" };
|
||||
Should.NotThrow(() => sut.InvokeValidateNoInterval());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.FOCAS.Cli-002: the FOCAS <c>subscribe</c> command — a near-verbatim copy
|
||||
/// of the Modbus subscribe command — must
|
||||
/// (a) serialise writes from the <c>OnDataChange</c> handler (raised from the
|
||||
/// driver's <c>PollGroupEngine</c> background thread) with a lock, so the
|
||||
/// "Subscribed to ..." banner write from the CliFx main thread cannot interleave
|
||||
/// with the first poll-driven change line; and
|
||||
/// (b) carry the explanatory comment that documents why <c>OnDataChange</c> uses
|
||||
/// <c>console.Output.WriteLine</c> (synchronous, on a driver background thread)
|
||||
/// instead of <c>System.Console</c> or the async <c>WriteLineAsync</c>. The
|
||||
/// rationale is non-obvious to a reader and the Modbus copy carries it; the FOCAS
|
||||
/// copy must too.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SubscribeCommandConsoleHandlerTests
|
||||
{
|
||||
private static string ReadSubscribeSource()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
dir = dir.Parent;
|
||||
dir.ShouldNotBeNull();
|
||||
return File.ReadAllText(Path.Combine(
|
||||
dir!.FullName, "src", "Drivers", "Cli", "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli",
|
||||
"Commands", "SubscribeCommand.cs"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeCommand_explains_why_OnDataChange_uses_console_Output_synchronously()
|
||||
{
|
||||
var source = ReadSubscribeSource();
|
||||
|
||||
// The comment must reference the CliFx console abstraction so future copy-pastes
|
||||
// do not lose the rationale.
|
||||
source.ShouldContain("CliFx console");
|
||||
source.ShouldContain("IConsole");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeCommand_serialises_console_writes_with_a_lock()
|
||||
{
|
||||
var source = ReadSubscribeSource();
|
||||
|
||||
// Both the banner write and the OnDataChange handler must share a writeLock so the
|
||||
// banner from the CliFx invocation thread cannot interleave with the first
|
||||
// poll-driven change line from the driver tick thread.
|
||||
source.ShouldContain("writeLock");
|
||||
source.ShouldContain("lock (writeLock)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="WriteCommand.ParseValue"/> across every FOCAS atomic type.
|
||||
/// Driver.FOCAS.Cli-001: malformed numeric input must surface as a friendly
|
||||
/// <see cref="CliFx.Exceptions.CommandException"/>, not a raw
|
||||
/// <see cref="FormatException"/> / <see cref="OverflowException"/> stack trace.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteCommandParseValueTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("0", false)]
|
||||
[InlineData("yes", true)]
|
||||
[InlineData("OFF", false)]
|
||||
public void ParseValue_Bit_accepts_common_boolean_aliases(string raw, bool expected)
|
||||
{
|
||||
WriteCommand.ParseValue(raw, FocasDataType.Bit).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Bit_rejects_garbage_as_CommandException()
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("maybe", FocasDataType.Bit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Byte_signed_range()
|
||||
{
|
||||
// FocasDataType.Byte is signed (PMC byte read returns int8).
|
||||
WriteCommand.ParseValue("-128", FocasDataType.Byte).ShouldBe((sbyte)-128);
|
||||
WriteCommand.ParseValue("127", FocasDataType.Byte).ShouldBe((sbyte)127);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int16_signed_range()
|
||||
{
|
||||
WriteCommand.ParseValue("-32768", FocasDataType.Int16).ShouldBe(short.MinValue);
|
||||
WriteCommand.ParseValue("32767", FocasDataType.Int16).ShouldBe(short.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Int32_parses_negative()
|
||||
{
|
||||
WriteCommand.ParseValue("-2147483648", FocasDataType.Int32).ShouldBe(int.MinValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Float32_invariant_culture()
|
||||
{
|
||||
WriteCommand.ParseValue("3.14", FocasDataType.Float32).ShouldBe(3.14f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_Float64_higher_precision()
|
||||
{
|
||||
WriteCommand.ParseValue("2.718281828", FocasDataType.Float64).ShouldBeOfType<double>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_String_passthrough()
|
||||
{
|
||||
WriteCommand.ParseValue("hello fanuc", FocasDataType.String).ShouldBe("hello fanuc");
|
||||
}
|
||||
|
||||
// Driver.FOCAS.Cli-001: malformed input must produce a CommandException (a clean
|
||||
// one-line CliFx error), NOT a raw FormatException stack trace. Previously the raw
|
||||
// BCL parser exceptions leaked, contradicting how the Bit path already handled bad
|
||||
// boolean input.
|
||||
[Theory]
|
||||
[InlineData("xyz", FocasDataType.Byte)]
|
||||
[InlineData("xyz", FocasDataType.Int16)]
|
||||
[InlineData("xyz", FocasDataType.Int32)]
|
||||
[InlineData("not-a-number", FocasDataType.Float32)]
|
||||
[InlineData("also-bad", FocasDataType.Float64)]
|
||||
public void ParseValue_non_numeric_for_numeric_types_throws_CommandException(
|
||||
string raw, FocasDataType type)
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue(raw, type));
|
||||
}
|
||||
|
||||
// OverflowException from out-of-range input must also surface as CommandException.
|
||||
[Theory]
|
||||
[InlineData("128", FocasDataType.Byte)] // sbyte max + 1
|
||||
[InlineData("-129", FocasDataType.Byte)] // sbyte min - 1
|
||||
[InlineData("32768", FocasDataType.Int16)] // short max + 1
|
||||
[InlineData("9999999999", FocasDataType.Int32)] // > int max
|
||||
public void ParseValue_overflow_for_numeric_types_throws_CommandException(
|
||||
string raw, FocasDataType type)
|
||||
{
|
||||
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue(raw, type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseValue_CommandException_message_names_the_type_and_value()
|
||||
{
|
||||
var ex = Should.Throw<CliFx.Exceptions.CommandException>(
|
||||
() => WriteCommand.ParseValue("xyz", FocasDataType.Int16));
|
||||
ex.Message.ShouldContain("xyz");
|
||||
ex.Message.ShouldContain("Int16");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100", FocasDataType.Int16, "R100:Int16")]
|
||||
[InlineData("X0.0", FocasDataType.Bit, "X0.0:Bit")]
|
||||
[InlineData("PARAM:1815/0", FocasDataType.Int32, "PARAM:1815/0:Int32")]
|
||||
[InlineData("MACRO:500", FocasDataType.Float64, "MACRO:500:Float64")]
|
||||
public void SynthesiseTagName_preserves_FOCAS_address_verbatim(
|
||||
string address, FocasDataType type, string expected)
|
||||
{
|
||||
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user