From 80ef8806e015b154410c41b135708874cfc85792 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 08:35:05 -0400 Subject: [PATCH] fix(driver-modbus-cli): resolve Low code-review findings (Driver.Modbus.Cli-003,004,005,006,007,008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- code-reviews/Driver.Modbus.Cli/findings.md | 72 ++++++-- docs/Driver.Modbus.Cli.md | 8 + .../Commands/ProbeCommand.cs | 46 ++++- .../Commands/ReadCommand.cs | 7 + .../Commands/SubscribeCommand.cs | 28 ++- .../Commands/WriteCommand.cs | 7 + .../ModbusCommandBase.cs | 30 ++++ .../CommandCancellationTests.cs | 66 +++++++ .../ModbusCommandBaseTests.cs | 164 ++++++++++++++++++ .../ProbeCommandTests.cs | 60 +++++++ .../WriteCommandRegionValidationTests.cs | 63 +++++++ 11 files changed, 533 insertions(+), 18 deletions(-) create mode 100644 tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/CommandCancellationTests.cs create mode 100644 tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ModbusCommandBaseTests.cs create mode 100644 tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ProbeCommandTests.cs create mode 100644 tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandRegionValidationTests.cs diff --git a/code-reviews/Driver.Modbus.Cli/findings.md b/code-reviews/Driver.Modbus.Cli/findings.md index 3eb3792..822595d 100644 --- a/code-reviews/Driver.Modbus.Cli/findings.md +++ b/code-reviews/Driver.Modbus.Cli/findings.md @@ -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. diff --git a/docs/Driver.Modbus.Cli.md b/docs/Driver.Modbus.Cli.md index 0127bed..9e55f01 100644 --- a/docs/Driver.Modbus.Cli.md +++ b/docs/Driver.Modbus.Cli.md @@ -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: diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs index f3fdb59..b419f49 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ProbeCommand.cs @@ -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); } } + + /// + /// Driver.Modbus.Cli-006: combine the driver-side with the + /// probe snapshot's OPC UA StatusCode into a single headline verdict. Order + /// of precedence: + /// + /// FAIL — driver did not reach + /// (Faulted / Reconnecting / Unknown) OR the snapshot reports Bad quality. + /// DEGRADED — driver Healthy but the snapshot quality is Uncertain. + /// OK — driver Healthy and snapshot Good. + /// + /// + 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)})"; + } } diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs index 544076e..b70dc15 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs @@ -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); diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs index 5f9168a..e1f1f74 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/SubscribeCommand.cs @@ -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); diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs index a605bf3..86ce8a4 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs @@ -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); diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs index b01ccdd..f7bb531 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ModbusCommandBase.cs @@ -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. /// protected string DriverInstanceId => $"modbus-cli-{Host}:{Port}"; + + /// + /// 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: + /// + /// --port: 1..65535 (IANA TCP port space, excludes the + /// "any" sentinel 0 and rejects negative / overflowed values). + /// --timeout-ms: strictly positive — a non-positive + /// would propagate as an immediate-timeout into the + /// driver and surface as a confusing TimeoutException. + /// --unit-id: 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 docs/Driver.Modbus.Cli.md.) + /// + /// + 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."); + } } diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/CommandCancellationTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/CommandCancellationTests.cs new file mode 100644 index 0000000..5ca2e69 --- /dev/null +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/CommandCancellationTests.cs @@ -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; + +/// +/// Covers Driver.Modbus.Cli-005: probe / read / write must swallow +/// so a Ctrl+C during InitializeAsync exits +/// cleanly instead of dumping a full stack trace through CliFx. SubscribeCommand +/// already handles this around its Task.Delay; these tests pin the same behaviour +/// to the connect/read/write commands. +/// The test pre-cancels the CliFx ; the driver's +/// ConnectAsync observes the token via Dns.GetHostAddressesAsync and throws +/// OCE before any socket I/O happens, so the test is hermetic — no real PLC needed. +/// +[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)); + } +} diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ModbusCommandBaseTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ModbusCommandBaseTests.cs new file mode 100644 index 0000000..6141d9d --- /dev/null +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ModbusCommandBaseTests.cs @@ -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; + +/// +/// Covers — the pure, deterministic mapping +/// from the base's host / port / unit-id / timeout / disable-reconnect flags onto a +/// ModbusDriverOptions. The CLI is one-shot so the background connectivity probe +/// must be disabled; AutoReconnect is the inverse of --disable-reconnect. +/// Also covers the input-range validation introduced for Driver.Modbus.Cli-003. +/// +[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 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(() => 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(() => 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(() => 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()); + } +} diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ProbeCommandTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ProbeCommandTests.cs new file mode 100644 index 0000000..922c78f --- /dev/null +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ProbeCommandTests.cs @@ -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; + +/// +/// Covers — the headline-string helper that +/// combines the driver-side with the probe snapshot's OPC UA +/// so the operator never sees the previous +/// contradictory pair (`Health: Healthy` above a `Status: Bad...` snapshot line — see +/// Driver.Modbus.Cli-006). +/// +[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"); + } +} diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandRegionValidationTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandRegionValidationTests.cs new file mode 100644 index 0000000..9fa0a0e --- /dev/null +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/WriteCommandRegionValidationTests.cs @@ -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; + +/// +/// Covers the branch validation inside : +/// 1. Driver.Modbus.Cli-002 — write to must use +/// --type Bool. +/// 2. Read-only regions (DiscreteInputs / InputRegisters) reject any write. +/// The actual driver call is never reached for these guard cases — they throw a +/// before the driver is constructed, +/// so we can exercise ExecuteAsync against an unreachable host. +/// +[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( + 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( + async () => await sut.ExecuteAsync(console)); + ex.Message.ShouldContain("Coils"); + ex.Message.ShouldContain("Bool"); + } +}