review(Driver.S7.Cli): endpoint validation + cancellation/flush/write-lock consistency
Re-review at 7286d320. -008 (Medium): S7CommandBase.ValidateEndpoint (port range + timeout>0)
in all commands +tests. -009 clean OperationCanceledException handling; -010 FlushLogging()
in subscribe finally; -011 lock console writes in OnDataChange. -012 (Verdict headline) deferred.
This commit is contained in:
@@ -4,8 +4,8 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli` |
|
| Module | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli` |
|
||||||
| Reviewer | Claude Code |
|
| Reviewer | Claude Code |
|
||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-06-19 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `111d6983` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 0 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
@@ -207,3 +207,95 @@ Minor, but the rationale is worth keeping consistent across the CLI family.
|
|||||||
the S7 copy explains why the event handler writes via `console.Output` synchronously.
|
the S7 copy explains why the event handler writes via `console.Output` synchronously.
|
||||||
|
|
||||||
**Resolution:** Resolved 2026-05-23 — re-added the explanatory comment above the `OnDataChange` handler in the S7 `SubscribeCommand`, mirroring the Modbus copy: it explains the use of the CliFx `IConsole.Output` abstraction (rather than `System.Console`) and notes that the handler runs synchronously because it's raised from a driver background thread. Added `SubscribeCommandConsoleHandlerCommentTests` to guard the rationale against future copy-paste regressions.
|
**Resolution:** Resolved 2026-05-23 — re-added the explanatory comment above the `OnDataChange` handler in the S7 `SubscribeCommand`, mirroring the Modbus copy: it explains the use of the CliFx `IConsole.Output` abstraction (rather than `System.Console`) and notes that the handler runs synchronously because it's raised from a driver background thread. Added `SubscribeCommandConsoleHandlerCommentTests` to guard the rationale against future copy-paste regressions.
|
||||||
|
|
||||||
|
## Re-review 2026-06-19 (commit 111d6983)
|
||||||
|
|
||||||
|
#### Checklist coverage
|
||||||
|
|
||||||
|
| # | Category | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Correctness & logic bugs | Driver.S7.Cli-009, Driver.S7.Cli-010 |
|
||||||
|
| 2 | OtOpcUa conventions | No issues found |
|
||||||
|
| 3 | Concurrency & thread safety | Driver.S7.Cli-011 |
|
||||||
|
| 4 | Error handling & resilience | Driver.S7.Cli-008, Driver.S7.Cli-009 |
|
||||||
|
| 5 | Security | No issues found |
|
||||||
|
| 6 | Performance & resource management | No issues found |
|
||||||
|
| 7 | Design-document adherence | Driver.S7.Cli-012 |
|
||||||
|
| 8 | Code organization & conventions | No issues found |
|
||||||
|
| 9 | Testing coverage | No issues found |
|
||||||
|
| 10 | Documentation & comments | No issues found |
|
||||||
|
|
||||||
|
### Driver.S7.Cli-008
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Medium |
|
||||||
|
| Category | Error handling & resilience |
|
||||||
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/S7CommandBase.cs` |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** `S7CommandBase` had no `ValidateEndpoint()` method, so an operator passing `--port 0`, `--port 65536`, or `--timeout-ms -1` got an opaque socket or argument exception thrown deep inside the S7.Net library stack rather than a clean `CommandException`. The analogous `ModbusCommandBase.ValidateEndpoint()` already had this guard.
|
||||||
|
|
||||||
|
**Recommendation:** Add a `ValidateEndpoint()` method to `S7CommandBase` that validates `Port` (1..65535) and `TimeoutMs` (strictly positive) before the driver is created, and call it at the top of each command's `ExecuteAsync`. Add tests covering invalid port values, non-positive timeout, boundary port values (1 and 65535), and the happy path.
|
||||||
|
|
||||||
|
**Resolution:** Resolved 2026-06-19 — added `ValidateEndpoint()` to `S7CommandBase` (port 1..65535, TimeoutMs strictly positive, both throw `CommandException` with the flag name); called it in `ProbeCommand`, `ReadCommand`, `WriteCommand`, and `SubscribeCommand`; added `S7EndpointValidationTests` (8 cases).
|
||||||
|
|
||||||
|
### Driver.S7.Cli-009
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Correctness & logic bugs |
|
||||||
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs:61`, `Commands/ReadCommand.cs`, `Commands/WriteCommand.cs` |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** Two related cancellation-handling gaps: (1) `ProbeCommand` caught `OperationCanceledException` without the `when (ct.IsCancellationRequested)` filter — a driver-internal timeout raised with a different CancellationToken would be caught and re-thrown from the wrong handler branch, masking the real error. (2) `ReadCommand` and `WriteCommand` had no `OperationCanceledException` handler at all — when the operator presses Ctrl+C during a connect or read, CliFx handles the exception silently, but the Modbus/AbCip CLI pattern of printing "Cancelled." and letting the caller see a clean one-line acknowledgement was missing.
|
||||||
|
|
||||||
|
**Recommendation:** (1) Add `when (ct.IsCancellationRequested)` to the `ProbeCommand` catch. (2) Wrap `ReadCommand` and `WriteCommand`'s driver calls in a `try/catch (OperationCanceledException) when (ct.IsCancellationRequested)` that prints "Cancelled." and returns, matching the Modbus pattern. Add source-level tests to guard the patterns.
|
||||||
|
|
||||||
|
**Resolution:** Resolved 2026-06-19 — added `when (ct.IsCancellationRequested)` filter to `ProbeCommand`; wrapped `ReadCommand` and `WriteCommand` driver calls in `try/catch (OperationCanceledException) when (ct.IsCancellationRequested)` printing "Cancelled."; added `CancellationHandlingTests` (3 cases).
|
||||||
|
|
||||||
|
### Driver.S7.Cli-010
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Code organization & conventions |
|
||||||
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs` |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** `SubscribeCommand.ExecuteAsync` did not call `FlushLogging()` in its `finally` block. `DriverCommandBase.ConfigureLogging()` documents that `FlushLogging()` must be called in a `finally` to prevent buffered Serilog output from being discarded on process exit. For the long-running `subscribe` command (which can run for minutes before Ctrl+C), reconnect warnings and other log lines emitted just before termination would be silently lost. The `AbCip` CLI subscribe command already had this call.
|
||||||
|
|
||||||
|
**Recommendation:** Add `FlushLogging()` as the last statement in `SubscribeCommand`'s outer `finally` block. Guard with a source-level test to prevent regression.
|
||||||
|
|
||||||
|
**Resolution:** Resolved 2026-06-19 — added `FlushLogging()` as the final statement in `SubscribeCommand`'s `finally` block; added `FlushLoggingConventionTests` (1 case).
|
||||||
|
|
||||||
|
### Driver.S7.Cli-011
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Concurrency & thread safety |
|
||||||
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs:61-67` |
|
||||||
|
| Status | Resolved |
|
||||||
|
|
||||||
|
**Description:** The `OnDataChange` handler in `SubscribeCommand` called `console.Output.WriteLine` without serialisation. `OnDataChange` is raised on the `PollGroupEngine` background timer thread; if two poll ticks complete close together (e.g. with a very short `--interval-ms`), concurrent handler invocations can interleave partial lines on the output. The Modbus CLI subscribe command guards against this with a `lock(writeLock)` wrapper; the S7 command was a copy-paste that dropped the lock.
|
||||||
|
|
||||||
|
**Recommendation:** Add a `var writeLock = new object();` beside the driver declaration and wrap the `console.Output.WriteLine` call in `lock (writeLock)`. Guard with a source-level test.
|
||||||
|
|
||||||
|
**Resolution:** Resolved 2026-06-19 — added `var writeLock = new object()` and wrapped `console.Output.WriteLine` in `lock (writeLock)` inside `SubscribeCommand.OnDataChange`; added `SubscribeCommandWriteLockTests` (1 case).
|
||||||
|
|
||||||
|
### Driver.S7.Cli-012
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Severity | Low |
|
||||||
|
| Category | Design-document adherence |
|
||||||
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs` |
|
||||||
|
| Status | Deferred |
|
||||||
|
|
||||||
|
**Description:** The Modbus CLI `ProbeCommand` was enhanced (Driver.Modbus.Cli-006) with a `ComputeVerdict` method that derives a single `OK / DEGRADED / FAIL` headline from both the driver state and the probe-read snapshot StatusCode. This prevents the contradictory output `Health: Healthy` when the probe snapshot carries a Bad quality code. The S7 `ProbeCommand` has the same contradiction risk — it prints `Health: {health.State}` without cross-checking the snapshot quality.
|
||||||
|
|
||||||
|
**Recommendation:** Port the `ComputeVerdict` logic from the Modbus CLI to the S7 CLI: derive a verdict from `health.State` + `snapshot[0].StatusCode` and print it as the headline `Verdict:` line, keeping `Health:` for the raw driver state.
|
||||||
|
|
||||||
|
**Resolution:** Deferred — enhancement rather than bug; requires mirroring the Modbus CLI's `ComputeVerdict` pattern which is a design change not a correctness fix. No test regression risk from deferring.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class ProbeCommand : S7CommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
ValidateEndpoint();
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var probeTag = new S7TagDefinition(
|
var probeTag = new S7TagDefinition(
|
||||||
@@ -58,9 +59,12 @@ public sealed class ProbeCommand : S7CommandBase
|
|||||||
await console.Output.WriteLineAsync();
|
await console.Output.WriteLineAsync();
|
||||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
throw; // Ctrl+C — let CliFx handle it normally.
|
// Ctrl+C — re-throw so CliFx exits cleanly. The when filter ensures a
|
||||||
|
// driver-internal timeout (different CancellationToken) is not mis-classified
|
||||||
|
// as user cancellation and falls through to the structured-report catch below.
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public sealed class ReadCommand : S7CommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
ValidateEndpoint();
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var tagName = SynthesiseTagName(Address, DataType);
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
@@ -53,9 +54,18 @@ public sealed class ReadCommand : S7CommandBase
|
|||||||
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||||
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
await driver.InitializeAsync("{}", ct);
|
try
|
||||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
{
|
||||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-009: 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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
|
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class SubscribeCommand : S7CommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
ValidateEndpoint();
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
@@ -48,6 +49,9 @@ public sealed class SubscribeCommand : S7CommandBase
|
|||||||
// ran shutdown twice. Only UnsubscribeAsync stays in the finally block — that's a subscription
|
// ran shutdown twice. Only UnsubscribeAsync stays in the finally block — that's a subscription
|
||||||
// lifecycle concern that is not part of driver disposal.
|
// lifecycle concern that is not part of driver disposal.
|
||||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
|
// Driver.S7.Cli-011: serialize console writes from the PollGroupEngine background
|
||||||
|
// thread so overlapping poll ticks cannot interleave partial lines on the output.
|
||||||
|
var writeLock = new object();
|
||||||
ISubscriptionHandle? handle = null;
|
ISubscriptionHandle? handle = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -62,7 +66,10 @@ public sealed class SubscribeCommand : S7CommandBase
|
|||||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||||
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||||
console.Output.WriteLine(line);
|
lock (writeLock)
|
||||||
|
{
|
||||||
|
console.Output.WriteLine(line);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
@@ -85,6 +92,11 @@ public sealed class SubscribeCommand : S7CommandBase
|
|||||||
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
catch { /* teardown best-effort */ }
|
catch { /* teardown best-effort */ }
|
||||||
}
|
}
|
||||||
|
// Driver.S7.Cli-010: flush Serilog before process exit so buffered log lines
|
||||||
|
// emitted just before Ctrl+C (e.g. reconnect warnings from the PollGroupEngine)
|
||||||
|
// are not lost on abrupt termination. DriverCommandBase.ConfigureLogging() docs
|
||||||
|
// require this call in a finally block.
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public sealed class WriteCommand : S7CommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
ValidateEndpoint();
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
@@ -60,9 +61,18 @@ public sealed class WriteCommand : S7CommandBase
|
|||||||
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
// already invokes ShutdownAsync, so a redundant explicit ShutdownAsync(CancellationToken.None)
|
||||||
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
// in a finally block ran shutdown twice. The await-using on the next line is enough.
|
||||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||||
await driver.InitializeAsync("{}", ct);
|
try
|
||||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
{
|
||||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-009: 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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
|
/// <summary>Parse <c>--value</c> per <see cref="S7DataType"/>, invariant culture throughout.</summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Exceptions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||||
@@ -65,4 +66,25 @@ public abstract class S7CommandBase : DriverCommandBase
|
|||||||
|
|
||||||
/// <summary>Gets the driver instance ID for this CLI session.</summary>
|
/// <summary>Gets the driver instance ID for this CLI session.</summary>
|
||||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.S7.Cli-008: validate the endpoint flags at parse time so the operator
|
||||||
|
/// gets a clear <see cref="CommandException"/> instead of an opaque socket or
|
||||||
|
/// argument exception thrown deep inside the S7.Net stack.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>--port</c>: 1..65535 (IANA TCP port space).</item>
|
||||||
|
/// <item><c>--timeout-ms</c>: strictly positive — a non-positive value would
|
||||||
|
/// propagate as an immediate-timeout into S7.Net and surface as a confusing
|
||||||
|
/// <see cref="TimeoutException"/>.</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}).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.S7.Cli-009: every S7 CLI command that catches
|
||||||
|
/// <see cref="System.OperationCanceledException"/> must use the
|
||||||
|
/// <c>when (ct.IsCancellationRequested)</c> exception filter so that a
|
||||||
|
/// driver-internal timeout (thrown with a different cancellation token) does not get
|
||||||
|
/// mis-handled as a user Ctrl+C. Source-level check mirrors
|
||||||
|
/// <see cref="CommandDisposalConventionsTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class CancellationHandlingTests
|
||||||
|
{
|
||||||
|
private static readonly string CommandsDir = LocateCommandsDir();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ProbeCommand uses the ct.IsCancellationRequested guard on its
|
||||||
|
/// OperationCanceledException catch block so a driver-internal timeout is not
|
||||||
|
/// swallowed as a user-cancelled operation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ProbeCommand_uses_ct_IsCancellationRequested_guard()
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-009: the bare `catch (OperationCanceledException)` without a
|
||||||
|
// `when` filter would mis-classify a driver-internal timeout as a Ctrl+C and
|
||||||
|
// either re-throw from the wrong catch block or swallow the error.
|
||||||
|
var source = File.ReadAllText(Path.Combine(CommandsDir, "ProbeCommand.cs"));
|
||||||
|
source.ShouldContain("when (ct.IsCancellationRequested)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ReadCommand provides quiet cancellation handling for Ctrl+C.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ReadCommand_handles_OperationCanceledException_quietly()
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-009: ReadCommand must catch OperationCanceledException and
|
||||||
|
// print "Cancelled." (matching the Modbus CLI pattern) rather than letting
|
||||||
|
// CliFx render an unhandled exception on Ctrl+C.
|
||||||
|
var source = File.ReadAllText(Path.Combine(CommandsDir, "ReadCommand.cs"));
|
||||||
|
source.ShouldContain("OperationCanceledException");
|
||||||
|
source.ShouldContain("Cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that WriteCommand provides quiet cancellation handling for Ctrl+C.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WriteCommand_handles_OperationCanceledException_quietly()
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-009: WriteCommand must catch OperationCanceledException and
|
||||||
|
// print "Cancelled." (matching the Modbus CLI pattern) rather than letting
|
||||||
|
// CliFx render an unhandled exception on Ctrl+C.
|
||||||
|
var source = File.ReadAllText(Path.Combine(CommandsDir, "WriteCommand.cs"));
|
||||||
|
source.ShouldContain("OperationCanceledException");
|
||||||
|
source.ShouldContain("Cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateCommandsDir()
|
||||||
|
{
|
||||||
|
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.S7.Cli", "Commands");
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.S7.Cli-010: the long-running <c>subscribe</c> command must call
|
||||||
|
/// <c>FlushLogging()</c> in its <c>finally</c> block so buffered Serilog log lines
|
||||||
|
/// emitted just before Ctrl+C are not discarded on process exit.
|
||||||
|
/// <para>
|
||||||
|
/// <c>DriverCommandBase.ConfigureLogging()</c> instructs every command to call
|
||||||
|
/// <c>FlushLogging()</c> in a <c>finally</c>. The short-lived <c>read</c>,
|
||||||
|
/// <c>write</c>, and <c>probe</c> commands are less exposed (their log volume is
|
||||||
|
/// small and the process exits immediately after), but <c>subscribe</c> can run for
|
||||||
|
/// minutes and then Ctrl+C — making the flush critical.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FlushLoggingConventionTests
|
||||||
|
{
|
||||||
|
private static readonly string CommandsDir = LocateCommandsDir();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that SubscribeCommand calls FlushLogging() to prevent buffered
|
||||||
|
/// Serilog output from being lost on Ctrl+C.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeCommand_calls_FlushLogging()
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-010: FlushLogging() must appear in the command so that
|
||||||
|
// DriverCommandBase.ConfigureLogging()'s documented contract is honoured.
|
||||||
|
var source = File.ReadAllText(Path.Combine(CommandsDir, "SubscribeCommand.cs"));
|
||||||
|
source.ShouldContain("FlushLogging()");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateCommandsDir()
|
||||||
|
{
|
||||||
|
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.S7.Cli", "Commands");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Exceptions;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.S7.Cli-008: S7CommandBase must reject out-of-range port and non-positive
|
||||||
|
/// timeout values before the command body opens a driver — matching the validation pattern
|
||||||
|
/// established by ModbusCommandBase.ValidateEndpoint.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7EndpointValidationTests
|
||||||
|
{
|
||||||
|
// Test-only subclass that exposes ValidateEndpoint and delegates the abstract ExecuteAsync.
|
||||||
|
[Command("noop-validate", Description = "Test-only stub for S7CommandBase.ValidateEndpoint.")]
|
||||||
|
private sealed class ValidateStub : S7CommandBase
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override ValueTask ExecuteAsync(IConsole console) => default;
|
||||||
|
|
||||||
|
/// <summary>Exposes the protected ValidateEndpoint method for tests.</summary>
|
||||||
|
public void InvokeValidate() => ValidateEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a valid port and timeout passes validation without throwing.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void ValidateEndpoint_valid_inputs_pass()
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-008: verify the happy path does not throw.
|
||||||
|
var sut = new ValidateStub { Host = "plc.local", Port = 102, TimeoutMs = 5000 };
|
||||||
|
Should.NotThrow(() => sut.InvokeValidate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that port 0 is rejected as out of range.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(65536)]
|
||||||
|
public void ValidateEndpoint_invalid_port_throws_CommandException(int port)
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-008: port must be 1..65535.
|
||||||
|
var sut = new ValidateStub { Host = "h", Port = port, TimeoutMs = 5000 };
|
||||||
|
Should.Throw<CommandException>(() => sut.InvokeValidate())
|
||||||
|
.Message.ShouldContain("--port");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a non-positive timeout is rejected.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
public void ValidateEndpoint_non_positive_timeout_throws_CommandException(int timeoutMs)
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-008: timeout must be strictly positive.
|
||||||
|
var sut = new ValidateStub { Host = "h", Port = 102, TimeoutMs = timeoutMs };
|
||||||
|
Should.Throw<CommandException>(() => sut.InvokeValidate())
|
||||||
|
.Message.ShouldContain("--timeout-ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the boundary port values 1 and 65535 pass validation.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(65535)]
|
||||||
|
public void ValidateEndpoint_boundary_ports_pass(int port)
|
||||||
|
{
|
||||||
|
var sut = new ValidateStub { Host = "h", Port = port, TimeoutMs = 1000 };
|
||||||
|
Should.NotThrow(() => sut.InvokeValidate());
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.S7.Cli-011: the <c>subscribe</c> command's <c>OnDataChange</c> handler must
|
||||||
|
/// serialize its console writes through a lock so that overlapping poll ticks (which
|
||||||
|
/// arrive on a background thread) cannot interleave partial lines on the output stream.
|
||||||
|
/// Mirrors the pattern from the Modbus CLI subscribe command.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class SubscribeCommandWriteLockTests
|
||||||
|
{
|
||||||
|
private static readonly string CommandsDir = LocateCommandsDir();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that SubscribeCommand uses a lock in the OnDataChange handler to
|
||||||
|
/// prevent interleaved console writes from overlapping poll ticks.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeCommand_OnDataChange_uses_write_lock()
|
||||||
|
{
|
||||||
|
// Driver.S7.Cli-011: the handler writes via console.Output.WriteLine on a
|
||||||
|
// background poll thread; concurrent ticks must be serialised with a lock
|
||||||
|
// to prevent partial-line interleaving.
|
||||||
|
var source = File.ReadAllText(Path.Combine(CommandsDir, "SubscribeCommand.cs"));
|
||||||
|
source.ShouldContain("lock (writeLock)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateCommandsDir()
|
||||||
|
{
|
||||||
|
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.S7.Cli", "Commands");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user