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` |
|
||||
| Reviewer | Claude Code |
|
||||
| Review date | 2026-05-22 |
|
||||
| Commit reviewed | `76d35d1` |
|
||||
| Review date | 2026-06-19 |
|
||||
| Commit reviewed | `111d6983` |
|
||||
| Status | Reviewed |
|
||||
| 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.
|
||||
|
||||
**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)
|
||||
{
|
||||
ConfigureLogging();
|
||||
ValidateEndpoint();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var probeTag = new S7TagDefinition(
|
||||
@@ -58,9 +59,12 @@ public sealed class ProbeCommand : S7CommandBase
|
||||
await console.Output.WriteLineAsync();
|
||||
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
|
||||
{
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class ReadCommand : S7CommandBase
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
ValidateEndpoint();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
var tagName = SynthesiseTagName(Address, DataType);
|
||||
@@ -53,9 +54,18 @@ public sealed class ReadCommand : S7CommandBase
|
||||
// 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.
|
||||
await using var driver = new S7Driver(options, DriverInstanceId);
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
try
|
||||
{
|
||||
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>
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed class SubscribeCommand : S7CommandBase
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
ValidateEndpoint();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
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
|
||||
// lifecycle concern that is not part of driver disposal.
|
||||
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;
|
||||
try
|
||||
{
|
||||
@@ -62,7 +66,10 @@ public sealed class SubscribeCommand : S7CommandBase
|
||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||
console.Output.WriteLine(line);
|
||||
lock (writeLock)
|
||||
{
|
||||
console.Output.WriteLine(line);
|
||||
}
|
||||
};
|
||||
|
||||
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); }
|
||||
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)
|
||||
{
|
||||
ConfigureLogging();
|
||||
ValidateEndpoint();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
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)
|
||||
// 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 driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||
try
|
||||
{
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
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>
|
||||
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