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:
Joseph Doherty
2026-05-23 11:11:55 -04:00
parent 2a941b255f
commit 6923be3aa2
14 changed files with 629 additions and 65 deletions

View File

@@ -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);
}
}