fix(driver-modbus-cli): resolve Low code-review findings (Driver.Modbus.Cli-003,004,005,006,007,008)

- Driver.Modbus.Cli-003: ModbusCommandBase.ValidateEndpoint rejects
  --port outside 1..65535, non-positive --timeout-ms, and --unit-id
  outside 1..247.
- Driver.Modbus.Cli-004: wrapped SubscribeCommand's OnDataChange handler
  body in a try/catch (warn-and-swallow) and serialised the console
  write through a lock.
- Driver.Modbus.Cli-005: Probe / Read / Write now catch the
  cancellation-during-init OperationCanceledException and print
  'Cancelled.' instead of dumping a stack trace.
- Driver.Modbus.Cli-006: ProbeCommand.ComputeVerdict derives the headline
  from BOTH the driver state and the probe snapshot's OPC UA quality
  class so the headline can't disagree with the wire result.
- Driver.Modbus.Cli-007: docs/Driver.Modbus.Cli.md carries an explicit
  'CLI scope' callout — the address-string grammar is a DriverConfig
  JSON feature; the CLI takes the structured triple only.
- Driver.Modbus.Cli-008: pinned BuildOptions, ValidateEndpoint, the
  region-validation guards, ComputeVerdict, and the cancellation-during-
  initialize paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:35:05 -04:00
parent f2ee027145
commit 80ef8806e0
11 changed files with 533 additions and 18 deletions

View File

@@ -0,0 +1,66 @@
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers Driver.Modbus.Cli-005: <c>probe</c> / <c>read</c> / <c>write</c> must swallow
/// <see cref="OperationCanceledException"/> so a Ctrl+C during InitializeAsync exits
/// cleanly instead of dumping a full stack trace through CliFx. <c>SubscribeCommand</c>
/// already handles this around its <c>Task.Delay</c>; these tests pin the same behaviour
/// to the connect/read/write commands.
/// The test pre-cancels the CliFx <see cref="FakeInMemoryConsole"/>; the driver's
/// <c>ConnectAsync</c> observes the token via <c>Dns.GetHostAddressesAsync</c> and throws
/// OCE before any socket I/O happens, so the test is hermetic — no real PLC needed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CommandCancellationTests
{
[Fact]
public async Task ProbeCommand_swallows_cancellation_during_initialize()
{
using var console = new FakeInMemoryConsole();
console.RequestCancellation(); // simulate Ctrl+C before ExecuteAsync runs
var sut = new ProbeCommand { Host = "127.0.0.1" };
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
[Fact]
public async Task ReadCommand_swallows_cancellation_during_initialize()
{
using var console = new FakeInMemoryConsole();
console.RequestCancellation();
var sut = new ReadCommand
{
Host = "127.0.0.1",
Region = ModbusRegion.HoldingRegisters,
Address = 0,
DataType = ModbusDataType.UInt16,
};
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
[Fact]
public async Task WriteCommand_swallows_cancellation_during_initialize()
{
using var console = new FakeInMemoryConsole();
console.RequestCancellation();
var sut = new WriteCommand
{
Host = "127.0.0.1",
Region = ModbusRegion.HoldingRegisters,
Address = 0,
DataType = ModbusDataType.UInt16,
Value = "42",
};
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
}

View File

@@ -0,0 +1,164 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers <see cref="ModbusCommandBase.BuildOptions"/> — the pure, deterministic mapping
/// from the base's host / port / unit-id / timeout / disable-reconnect flags onto a
/// <c>ModbusDriverOptions</c>. The CLI is one-shot so the background connectivity probe
/// must be disabled; <c>AutoReconnect</c> is the inverse of <c>--disable-reconnect</c>.
/// Also covers the input-range validation introduced for Driver.Modbus.Cli-003.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCommandBaseTests
{
// Test-only ModbusCommandBase concrete subclass that exposes the protected BuildOptions
// helper + ValidateEndpoint. 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 ModbusCommandBase.BuildOptions.")]
private sealed class ProbeOnly : ModbusCommandBase
{
public override ValueTask ExecuteAsync(IConsole console) => default;
public ModbusDriverOptions Invoke(IReadOnlyList<ModbusTagDefinition> tags) => BuildOptions(tags);
public void InvokeValidate() => ValidateEndpoint();
}
[Fact]
public void BuildOptions_disables_probe_for_one_shot_cli_runs()
{
var sut = new ProbeOnly { Host = "10.0.0.5" };
var options = sut.Invoke([]);
options.Probe.ShouldNotBeNull();
options.Probe.Enabled.ShouldBeFalse();
}
[Fact]
public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
{
var sut = new ProbeOnly { Host = "h", TimeoutMs = 7500 };
var options = sut.Invoke([]);
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
}
[Fact]
public void BuildOptions_AutoReconnect_defaults_to_true_when_flag_unset()
{
var sut = new ProbeOnly { Host = "h" };
var options = sut.Invoke([]);
options.AutoReconnect.ShouldBeTrue();
}
[Fact]
public void BuildOptions_AutoReconnect_becomes_false_when_disable_reconnect_flag_set()
{
var sut = new ProbeOnly { Host = "h", DisableAutoReconnect = true };
var options = sut.Invoke([]);
options.AutoReconnect.ShouldBeFalse();
}
[Fact]
public void BuildOptions_flows_host_port_unit_through()
{
var sut = new ProbeOnly { Host = "plc.shop.local", Port = 5020, UnitId = 17, TimeoutMs = 3000 };
var options = sut.Invoke([]);
options.Host.ShouldBe("plc.shop.local");
options.Port.ShouldBe(5020);
options.UnitId.ShouldBe((byte)17);
}
[Fact]
public void BuildOptions_forwards_tag_list_verbatim()
{
var sut = new ProbeOnly { Host = "h" };
var tag = new ModbusTagDefinition(
Name: "T", Region: ModbusRegion.HoldingRegisters, Address: 0, DataType: ModbusDataType.UInt16);
var options = sut.Invoke([tag]);
options.Tags.Count.ShouldBe(1);
options.Tags[0].ShouldBeSameAs(tag);
}
// --- Driver.Modbus.Cli-003: parse-time endpoint validation -------------------------------
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(65536)]
[InlineData(int.MinValue)]
[InlineData(int.MaxValue)]
public void ValidateEndpoint_rejects_port_outside_1_to_65535(int port)
{
var sut = new ProbeOnly { Host = "h", Port = port };
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
[Theory]
[InlineData(1)]
[InlineData(502)]
[InlineData(65535)]
public void ValidateEndpoint_accepts_port_in_range(int port)
{
var sut = new ProbeOnly { Host = "h", Port = port };
Should.NotThrow(() => sut.InvokeValidate());
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-2000)]
public void ValidateEndpoint_rejects_non_positive_timeout(int timeoutMs)
{
var sut = new ProbeOnly { Host = "h", TimeoutMs = timeoutMs };
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
[Theory]
[InlineData(0)] // broadcast — disallowed for unicast read/write requests
[InlineData(248)]
[InlineData(255)]
public void ValidateEndpoint_rejects_unit_id_outside_1_to_247(byte unitId)
{
var sut = new ProbeOnly { Host = "h", UnitId = unitId };
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
[Theory]
[InlineData(1)]
[InlineData(247)]
[InlineData(50)]
public void ValidateEndpoint_accepts_unit_id_in_range(byte unitId)
{
var sut = new ProbeOnly { Host = "h", UnitId = unitId };
Should.NotThrow(() => sut.InvokeValidate());
}
[Fact]
public void ValidateEndpoint_accepts_default_options()
{
// Defaults: Port=502, UnitId=1, TimeoutMs=2000. All inside the valid ranges.
var sut = new ProbeOnly { Host = "h" };
Should.NotThrow(() => sut.InvokeValidate());
}
}

View File

@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers <see cref="ProbeCommand.ComputeVerdict"/> — the headline-string helper that
/// combines the driver-side <see cref="DriverState"/> with the probe snapshot's OPC UA
/// <see cref="DataValueSnapshot.StatusCode"/> so the operator never sees the previous
/// contradictory pair (`Health: Healthy` above a `Status: Bad...` snapshot line — see
/// Driver.Modbus.Cli-006).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ProbeCommandTests
{
[Fact]
public void ComputeVerdict_returns_OK_when_state_is_Healthy_and_status_is_Good()
{
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode: 0x00000000u);
verdict.ShouldContain("OK");
}
[Theory]
[InlineData(DriverState.Faulted)]
[InlineData(DriverState.Reconnecting)]
[InlineData(DriverState.Unknown)]
public void ComputeVerdict_returns_FAIL_when_driver_state_is_not_Healthy(DriverState state)
{
var verdict = ProbeCommand.ComputeVerdict(state, statusCode: 0x00000000u);
verdict.ShouldContain("FAIL");
}
[Theory]
[InlineData(0x80050000u)] // BadCommunicationError
[InlineData(0x800A0000u)] // BadTimeout
[InlineData(0x80740000u)] // BadTypeMismatch
[InlineData(0x80000000u)] // generic Bad
public void ComputeVerdict_returns_FAIL_when_snapshot_status_is_Bad_even_if_driver_state_Healthy(uint statusCode)
{
// Driver.Modbus.Cli-006: a successful InitializeAsync sets DriverState.Healthy, but
// the FC03 probe read may still fail (snapshot.StatusCode != Good). Previously the
// headline reported Healthy while the snapshot line below showed Bad. The verdict
// must reflect the actual probe-read outcome.
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode);
verdict.ShouldContain("FAIL");
}
[Fact]
public void ComputeVerdict_returns_DEGRADED_for_uncertain_status_with_healthy_driver()
{
var verdict = ProbeCommand.ComputeVerdict(DriverState.Healthy, statusCode: 0x40000000u);
verdict.ShouldContain("DEGRADED");
}
}

View File

@@ -0,0 +1,63 @@
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
/// <summary>
/// Covers the branch validation inside <see cref="WriteCommand.ExecuteAsync"/>:
/// 1. Driver.Modbus.Cli-002 — write to <see cref="ModbusRegion.Coils"/> must use
/// <c>--type Bool</c>.
/// 2. Read-only regions (DiscreteInputs / InputRegisters) reject any write.
/// The actual driver call is never reached for these guard cases — they throw a
/// <see cref="CliFx.Exceptions.CommandException"/> before the driver is constructed,
/// so we can exercise <c>ExecuteAsync</c> against an unreachable host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class WriteCommandRegionValidationTests
{
[Theory]
[InlineData(ModbusRegion.DiscreteInputs, ModbusDataType.Bool, "0")]
[InlineData(ModbusRegion.InputRegisters, ModbusDataType.UInt16, "1")]
public async Task ExecuteAsync_rejects_read_only_regions(
ModbusRegion region, ModbusDataType type, string value)
{
var sut = new WriteCommand
{
// Host is required, but the guard fires before any socket use.
Host = "127.0.0.1",
Region = region,
Address = 0,
DataType = type,
Value = value,
};
using var console = new FakeInMemoryConsole();
await Should.ThrowAsync<CliFx.Exceptions.CommandException>(
async () => await sut.ExecuteAsync(console));
}
[Theory]
[InlineData(ModbusDataType.UInt16)]
[InlineData(ModbusDataType.Int16)]
[InlineData(ModbusDataType.Float32)]
[InlineData(ModbusDataType.Int32)]
public async Task ExecuteAsync_rejects_non_Bool_type_for_Coils_region(ModbusDataType type)
{
var sut = new WriteCommand
{
Host = "127.0.0.1",
Region = ModbusRegion.Coils,
Address = 5,
DataType = type,
Value = "42",
};
using var console = new FakeInMemoryConsole();
var ex = await Should.ThrowAsync<CliFx.Exceptions.CommandException>(
async () => await sut.ExecuteAsync(console));
ex.Message.ShouldContain("Coils");
ex.Message.ShouldContain("Bool");
}
}