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:
Joseph Doherty
2026-06-19 12:08:45 -04:00
parent b0f9b8016a
commit f8bf067243
10 changed files with 390 additions and 11 deletions
@@ -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");
}
}
@@ -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());
}
}
@@ -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");
}
}