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

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
| Open findings | 6 |
| Open findings | 0 |
## Checklist coverage
@@ -87,7 +87,7 @@ message explaining coils carry a single bit.
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs:14-24` |
| Status | Open |
| Status | Resolved |
**Description:** `Port` (`int`) and `TimeoutMs` (`int`) accept any 32-bit value,
including negatives and ports above 65535. `UnitId` is a `byte`, so it accepts
@@ -103,7 +103,13 @@ error. None of these are validated at parse time.
message — consistent with how `WriteCommand` already rejects bad regions and
boolean strings.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — added `ModbusCommandBase.ValidateEndpoint()`
that throws `CliFx.Exceptions.CommandException` for `--port` outside 1..65535,
non-positive `--timeout-ms`, and `--unit-id` outside 1..247 (Modbus spec unicast
range — rejects the broadcast 0 and reserved 248-255). Each of `ProbeCommand`,
`ReadCommand`, `WriteCommand`, and `SubscribeCommand` now calls it at the top of
`ExecuteAsync` after `ConfigureLogging()`. Regression tests live in
`ModbusCommandBaseTests` (range + boundary cases for all three knobs).
### Driver.Modbus.Cli-004
@@ -113,7 +119,7 @@ boolean strings.
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs:61-67` |
| Status | Open |
| Status | Resolved |
**Description:** The `OnDataChange` handler is invoked from the driver's
`PollGroupEngine` background thread and calls `console.Output.WriteLine`
@@ -127,7 +133,11 @@ any synchronization, so overlapping poll ticks could interleave partial lines.
write failures so a transient console-write error cannot tear down the poll loop.
A single `lock` around the write also removes the interleave risk.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — wrapped the `OnDataChange` handler in
`try/catch (Exception)` that logs to Serilog at Warning and swallows the failure
so a transient console-write error cannot fault the `PollGroupEngine` background
loop. The console write is also serialized through a local `lock` object, removing
the partial-line interleave risk when multiple poll ticks overlap.
### Driver.Modbus.Cli-005
@@ -136,7 +146,7 @@ A single `lock` around the write also removes the interleave risk.
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:21-54`; `Commands/ReadCommand.cs:46-75`; `Commands/WriteCommand.cs:54-89` |
| Status | Open |
| Status | Resolved |
**Description:** All three commands call `ConfigureLogging()` then
`console.RegisterCancellationHandler()`, but if the operator presses Ctrl+C
@@ -152,7 +162,14 @@ commands do not catch it around their driver calls.
the noisy trace on Ctrl+C-during-connect is acceptable. Consistency with
`SubscribeCommand`'s handling is the cleaner choice.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — added a
`catch (OperationCanceledException) when (ct.IsCancellationRequested)` clause
around the driver-call bodies in `ProbeCommand`, `ReadCommand`, and `WriteCommand`
that prints `Cancelled.` and falls through to the existing `finally`-block
shutdown. Matches `SubscribeCommand`'s existing handling so all four commands now
exit quietly on Ctrl+C. Regression tests in `CommandCancellationTests` pre-cancel
the CliFx `FakeInMemoryConsole` before calling `ExecuteAsync` and assert no
exception escapes.
### Driver.Modbus.Cli-006
@@ -161,7 +178,7 @@ the noisy trace on Ctrl+C-during-connect is acceptable. Consistency with
| Severity | Low |
| Category | Error handling & resilience |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs:35-53` |
| Status | Open |
| Status | Resolved |
**Description:** `probe` reports `Health: {health.State}` from `GetHealth()`.
After a successful `InitializeAsync` the driver sets state to `Healthy`
@@ -179,7 +196,14 @@ snapshot's `StatusCode` (Good vs Bad) rather than — or in addition to — the
`State`, or print a single combined verdict line so the two cannot contradict each
other.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — `ProbeCommand` now prints a single combined
`Verdict:` line above the bare driver-state line; the headline is computed by a
new `ProbeCommand.ComputeVerdict(DriverState, statusCode)` helper that combines
the driver state with the probe snapshot's OPC UA quality class (top 2 bits of
the StatusCode). Verdict is `FAIL` whenever the driver is not Healthy OR the
snapshot is Bad, `DEGRADED` when the driver is Healthy but the snapshot is
Uncertain, and `OK` only when both are Good. Regression tests in
`ProbeCommandTests` pin the verdict-grid behaviour.
### Driver.Modbus.Cli-007
@@ -188,7 +212,7 @@ other.
| Severity | Low |
| Category | Design-document adherence |
| Location | `docs/Driver.Modbus.Cli.md:124-156`; `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs` |
| Status | Open |
| Status | Resolved |
**Description:** `docs/Driver.Modbus.Cli.md` devotes a whole "v2 addressing
grammar" section to the industry-standard tag-address strings (`40001:F:CDAB`,
@@ -205,7 +229,14 @@ driver's address-string parser (and `--family` for the DL205/MELSEC native
syntax), or scope the "v2 addressing grammar" section of the doc to note it
applies to `DriverConfig` JSON and is not a CLI flag.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — chose the doc-scoping fix (an
`--address-string` flag is a bigger feature that warrants its own design
discussion). Added a "CLI scope" callout to the top of the
`docs/Driver.Modbus.Cli.md` "v2 addressing grammar" section stating the CLI
accepts only the structured `--region` + `--address` + `--type` triple and that
the address-string grammar is a `DriverConfig` JSON feature. The pre-existing
closing paragraph already said the same thing; the new callout makes it visible
before the grammar examples instead of after them. Code surface left unchanged.
### Driver.Modbus.Cli-008
@@ -214,7 +245,7 @@ applies to `DriverConfig` JSON and is not a CLI flag.
| Severity | Low |
| Category | Testing coverage |
| Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/` |
| Status | Open |
| Status | Resolved |
**Description:** The test project covers only the two pure-function seams:
`ReadCommand.SynthesiseTagName` and `WriteCommand.ParseValue`. There is no coverage
@@ -231,4 +262,19 @@ likely to regress and is currently untested. The validation gaps in findings
setters and assert the produced `ModbusDriverOptions`). Once findings 002/003 are
fixed, add tests for the new validation paths.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-23 — added four new test classes covering the
previously untested branch logic:
- `ModbusCommandBaseTests` — six tests over `BuildOptions` (probe disabled,
`TimeoutMs``Timeout` mapping, `AutoReconnect` tracking
`--disable-reconnect` in both directions, host/port/unit flow-through, tag
pass-through) plus the new `ValidateEndpoint` range-check tests for finding
003 (port 1..65535, timeout-ms > 0, unit-id 1..247).
- `WriteCommandRegionValidationTests` — read-only region rejection
(DiscreteInputs / InputRegisters) and the Coils-non-Bool guard for finding
002.
- `ProbeCommandTests` — the new `ComputeVerdict` helper for finding 006
(OK / DEGRADED / FAIL grid).
- `CommandCancellationTests` — Ctrl+C-during-initialize for `ProbeCommand` /
`ReadCommand` / `WriteCommand` for finding 005.
Total test count grew from 18 to 64; all pass.

View File

@@ -122,6 +122,14 @@ gives plausible values is the correct one for that device.
## v2 addressing grammar
> **CLI scope:** the `read` / `write` / `subscribe` commands accept only
> the structured `--region` + `--address` + `--type` triple. The
> address-string grammar below is a **`DriverConfig` JSON** feature
> consumed by the driver itself; it is not reachable from this CLI's
> flags. To experiment with it via the CLI, use the structured flags;
> to deploy spreadsheets as-is, hand-author a `DriverConfig` and run
> the server.
The driver accepts the industry-standard tag-address grammar so you can
paste tag spreadsheets from Wonderware / Kepware / Ignition without
per-row manual translation. Full reference + grammar rules:

View File

@@ -1,5 +1,6 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Commands;
@@ -21,6 +22,7 @@ public sealed class ProbeCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
// Build with one probe tag + Probe.Enabled=false so InitializeAsync connects the
@@ -39,17 +41,59 @@ public sealed class ProbeCommand : ModbusCommandBase
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
// Driver.Modbus.Cli-006: derive the headline verdict from BOTH the driver state
// AND the probe-read StatusCode so the operator never sees the previous
// contradictory pair (`Health: Healthy` over a Bad snapshot line). The bare
// driver state is still printed below for diagnostics, but the verdict is what
// the operator scans first.
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
await console.Output.WriteLineAsync($"Host: {Host}:{Port} (unit {UnitId})");
await console.Output.WriteLineAsync($"Health: {health.State}");
await console.Output.WriteLineAsync($"Verdict: {verdict}");
await console.Output.WriteLineAsync($"Driver state: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync(
SnapshotFormatter.Format($"HR[{ProbeAddress}]", snapshot[0]));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.Modbus.Cli-005: Ctrl+C during InitializeAsync — exit quietly so CliFx
// does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>
/// Driver.Modbus.Cli-006: combine the driver-side <see cref="DriverState"/> with the
/// probe snapshot's OPC UA <c>StatusCode</c> into a single headline verdict. Order
/// of precedence:
/// <list type="number">
/// <item><c>FAIL</c> — driver did not reach <see cref="DriverState.Healthy"/>
/// (Faulted / Reconnecting / Unknown) OR the snapshot reports Bad quality.</item>
/// <item><c>DEGRADED</c> — driver Healthy but the snapshot quality is Uncertain.</item>
/// <item><c>OK</c> — driver Healthy and snapshot Good.</item>
/// </list>
/// </summary>
public static string ComputeVerdict(DriverState state, uint statusCode)
{
// OPC UA StatusCode top 2 bits encode the quality class:
// 0x00xxxxxx → Good, 0x40xxxxxx → Uncertain, 0x80xxxxxx / 0xC0xxxxxx → Bad.
var qualityClass = statusCode & 0xC0000000u;
var snapshotGood = qualityClass == 0x00000000u;
var snapshotUncertain = qualityClass == 0x40000000u;
if (state != DriverState.Healthy || !snapshotGood && !snapshotUncertain)
return $"FAIL (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
if (snapshotUncertain)
return $"DEGRADED (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
return $"OK (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
}
}

View File

@@ -46,6 +46,7 @@ public sealed class ReadCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Region, Address, DataType);
@@ -68,6 +69,12 @@ public sealed class ReadCommand : ModbusCommandBase
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(tagName, snapshot[0]));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.Modbus.Cli-005: Ctrl+C during driver connect/read — exit quietly so
// CliFx does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);

View File

@@ -53,6 +53,7 @@ public sealed class SubscribeCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
@@ -69,6 +70,9 @@ public sealed class SubscribeCommand : ModbusCommandBase
var options = BuildOptions([tag]);
await using var driver = new ModbusDriver(options, DriverInstanceId);
// Driver.Modbus.Cli-004: serialize console writes from the PollGroupEngine background
// thread so overlapping poll ticks can't interleave partial lines.
var writeLock = new object();
ISubscriptionHandle? handle = null;
try
{
@@ -78,10 +82,26 @@ public sealed class SubscribeCommand : ModbusCommandBase
// analyzer flags it + IConsole is the testable abstraction).
driver.OnDataChange += (_, e) =>
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
console.Output.WriteLine(line);
// Driver.Modbus.Cli-004: swallow + log write failures so a transient stdout
// error (closed pipe, IO exception on a redirected stream) cannot tear down
// the poll-engine background loop. Without this guard the unhandled
// exception would fault the long-running subscribe.
try
{
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
lock (writeLock)
{
console.Output.WriteLine(line);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Warning(ex,
"SubscribeCommand: console write failed for {Tag}; continuing poll loop.",
e.FullReference);
}
};
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);

View File

@@ -54,6 +54,7 @@ public sealed class WriteCommand : ModbusCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
if (Region is not (ModbusRegion.Coils or ModbusRegion.HoldingRegisters))
@@ -92,6 +93,12 @@ public sealed class WriteCommand : ModbusCommandBase
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(tagName, results[0]));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Driver.Modbus.Cli-005: Ctrl+C during driver connect/write — exit quietly so
// CliFx does not render a full stack trace for a user-initiated cancellation.
await console.Output.WriteLineAsync("Cancelled.");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);

View File

@@ -1,4 +1,5 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli;
@@ -57,4 +58,33 @@ public abstract class ModbusCommandBase : DriverCommandBase
/// multiple endpoints in parallel can distinguish the logs.
/// </summary>
protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}";
/// <summary>
/// Driver.Modbus.Cli-003: validate the endpoint flags at parse time so the operator
/// gets a clear CliFx error instead of an opaque socket / argument exception thrown
/// deep inside the driver. Ranges:
/// <list type="bullet">
/// <item><c>--port</c>: 1..65535 (IANA TCP port space, excludes the
/// "any" sentinel 0 and rejects negative / overflowed values).</item>
/// <item><c>--timeout-ms</c>: strictly positive — a non-positive
/// <see cref="TimeSpan"/> would propagate as an immediate-timeout into the
/// driver and surface as a confusing TimeoutException.</item>
/// <item><c>--unit-id</c>: 1..247 — the Modbus spec unicast unit-id range.
/// 0 is the broadcast address and not valid for read/write requests; 248-255
/// are reserved. (Documented in <c>docs/Driver.Modbus.Cli.md</c>.)</item>
/// </list>
/// </summary>
protected void ValidateEndpoint()
{
if (Port < 1 || Port > 65535)
throw new CommandException(
$"--port must be in 1..65535 (got {Port}).");
if (TimeoutMs <= 0)
throw new CommandException(
$"--timeout-ms must be strictly positive (got {TimeoutMs}).");
if (UnitId < 1 || UnitId > 247)
throw new CommandException(
$"--unit-id must be in 1..247 per the Modbus spec (got {UnitId}); " +
$"0 is the broadcast address, 248-255 are reserved.");
}
}

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