fix(driver-abcip-cli): resolve Low code-review findings (Driver.AbCip.Cli-003,004,005,006,007,008)
- Driver.AbCip.Cli-003: SubscribeCommand prints the 'Subscribed' banner BEFORE wiring OnDataChange so the main thread can't interleave its write with the poll-thread handler. - Driver.AbCip.Cli-004: AbCipCommandBase.Timeout and SubscribeCommand validate TimeoutMs / IntervalMs and throw CommandException on non-positive values. - Driver.AbCip.Cli-005: every command now calls FlushLogging() in its finally block. - Driver.AbCip.Cli-006: Timeout init throws NotSupportedException with a pointer at TimeoutMs instead of silently swallowing assignments. - Driver.AbCip.Cli-007: added AbCipCommandBaseTests covering BuildOptions shape, probe / controller-browse / alarm toggles, host address, family selection, tag list passthrough. - Driver.AbCip.Cli-008: rewrote the opening paragraph in docs/Driver.AbCip.Cli.md to credit the six-CLI roster with a pointer at docs/DriverClis.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 6 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ into `AbCipCommandBase`.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs:50-56,60-61` |
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs:50-56,60-61` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The `OnDataChange` handler writes change lines to `console.Output`
|
**Description:** The `OnDataChange` handler writes change lines to `console.Output`
|
||||||
(a `TextWriter`) from the driver's poll-engine callback thread, while the command's
|
(a `TextWriter`) from the driver's poll-engine callback thread, while the command's
|
||||||
@@ -112,7 +112,12 @@ during the watch loop widens it.
|
|||||||
writes during the subscription with a shared lock so poll-thread and main-thread
|
writes during the subscription with a shared lock so poll-thread and main-thread
|
||||||
output cannot interleave.
|
output cannot interleave.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — moved the "Subscribed to ... Ctrl+C to stop."
|
||||||
|
banner write to BEFORE `driver.OnDataChange +=` (and therefore before
|
||||||
|
`SubscribeAsync`). With the handler not yet attached when the banner runs, the
|
||||||
|
poll thread cannot fire change events into `console.Output` concurrently with the
|
||||||
|
main-thread banner write. After `+=` the only writer to `console.Output` is the
|
||||||
|
poll-thread handler, so no interleaving is possible.
|
||||||
|
|
||||||
### Driver.AbCip.Cli-004
|
### Driver.AbCip.Cli-004
|
||||||
|
|
||||||
@@ -121,7 +126,7 @@ output cannot interleave.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs:28,58`; `AbCipCommandBase.cs:26-34` |
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Commands/SubscribeCommand.cs:28,58`; `AbCipCommandBase.cs:26-34` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `--interval-ms` (`IntervalMs`) is taken verbatim and passed as
|
**Description:** `--interval-ms` (`IntervalMs`) is taken verbatim and passed as
|
||||||
`TimeSpan.FromMilliseconds(IntervalMs)` to `SubscribeAsync` with no validation. A
|
`TimeSpan.FromMilliseconds(IntervalMs)` to `SubscribeAsync` with no validation. A
|
||||||
@@ -135,7 +140,16 @@ downstream component to sanitise operator input is fragile. `--timeout-ms` on
|
|||||||
`ExecuteAsync` / in `AbCipCommandBase`, throwing a `CommandException` with the
|
`ExecuteAsync` / in `AbCipCommandBase`, throwing a `CommandException` with the
|
||||||
accepted range when out of bounds.
|
accepted range when out of bounds.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — added `SubscribeCommand.ValidateInterval(int)`
|
||||||
|
(throws `CommandException` for `IntervalMs <= 0`) called at the top of
|
||||||
|
`SubscribeCommand.ExecuteAsync`; moved `TimeoutMs > 0` validation into the
|
||||||
|
`AbCipCommandBase.Timeout` getter (throws `CommandException` for non-positive
|
||||||
|
`TimeoutMs`) and added a `_ = Timeout` touch in `SubscribeCommand.ExecuteAsync` to
|
||||||
|
fire that guard before the driver opens. Other commands trip the same guard
|
||||||
|
naturally via `BuildOptions(...).Timeout`. Regression tests
|
||||||
|
`AbCipCommandBaseTests.Timeout_get_throws_CommandException_when_TimeoutMs_is_non_positive`
|
||||||
|
and `SubscribeCommandIntervalTests.ValidateInterval_rejects_non_positive` cover
|
||||||
|
both paths.
|
||||||
|
|
||||||
### Driver.AbCip.Cli-005
|
### Driver.AbCip.Cli-005
|
||||||
|
|
||||||
@@ -144,7 +158,7 @@ accepted range when out of bounds.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs:51-59` |
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs:51-59` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `ConfigureLogging` assigns a freshly created Serilog logger to the
|
**Description:** `ConfigureLogging` assigns a freshly created Serilog logger to the
|
||||||
process-global `Log.Logger` but never calls `Log.CloseAndFlush()`. For a short-lived
|
process-global `Log.Logger` but never calls `Log.CloseAndFlush()`. For a short-lived
|
||||||
@@ -159,7 +173,12 @@ module's review.)
|
|||||||
`AppDomain.ProcessExit` or a `finally` in the command), or have the CLI use a
|
`AppDomain.ProcessExit` or a `finally` in the command), or have the CLI use a
|
||||||
disposable logger scoped to `ExecuteAsync`.
|
disposable logger scoped to `ExecuteAsync`.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — `Driver.Cli.Common` already exposes
|
||||||
|
`DriverCommandBase.FlushLogging()` (a `Log.CloseAndFlush()` wrapper); the AB CIP
|
||||||
|
CLI was not calling it. Added `FlushLogging()` in the `finally` block of all four
|
||||||
|
commands (`ProbeCommand`, `ReadCommand`, `WriteCommand`, `SubscribeCommand`) so
|
||||||
|
buffered Serilog output is flushed before the process exits, including the
|
||||||
|
Ctrl+C-driven `subscribe` path. No edits to `Driver.Cli.Common` were needed.
|
||||||
|
|
||||||
### Driver.AbCip.Cli-006
|
### Driver.AbCip.Cli-006
|
||||||
|
|
||||||
@@ -168,7 +187,7 @@ disposable logger scoped to `ExecuteAsync`.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs:29-34` |
|
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs:29-34` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `AbCipCommandBase` overrides the abstract `DriverCommandBase.Timeout`
|
**Description:** `AbCipCommandBase` overrides the abstract `DriverCommandBase.Timeout`
|
||||||
property with a getter derived from `TimeoutMs` and an empty `init` body
|
property with a getter derived from `TimeoutMs` and an empty `init` body
|
||||||
@@ -183,9 +202,13 @@ bug.
|
|||||||
**Recommendation:** Either drop the `init` accessor entirely (make the override a
|
**Recommendation:** Either drop the `init` accessor entirely (make the override a
|
||||||
get-only expression-bodied property) or have the empty `init` throw
|
get-only expression-bodied property) or have the empty `init` throw
|
||||||
`NotSupportedException` to make the "driven by TimeoutMs" contract explicit and
|
`NotSupportedException` to make the "driven by TimeoutMs" contract explicit and
|
||||||
fail-fast.
|
fail-fast. (Drop is not viable because the abstract base declares `{ get; init; }`
|
||||||
|
and an override must provide both accessors.)
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — the `init` accessor now throws
|
||||||
|
`NotSupportedException` with a message pointing the caller at `TimeoutMs`. A new
|
||||||
|
test `AbCipCommandBaseTests.Timeout_setter_is_inert_and_does_not_silently_swallow_assignments`
|
||||||
|
asserts that an object-initializer assignment to `Timeout` fails fast.
|
||||||
|
|
||||||
### Driver.AbCip.Cli-007
|
### Driver.AbCip.Cli-007
|
||||||
|
|
||||||
@@ -194,7 +217,7 @@ fail-fast.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/WriteCommandParseValueTests.cs` |
|
| Location | `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/WriteCommandParseValueTests.cs` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The only test file covers `WriteCommand.ParseValue` and
|
**Description:** The only test file covers `WriteCommand.ParseValue` and
|
||||||
`ReadCommand.SynthesiseTagName` — both pure static helpers. There is no coverage for
|
`ReadCommand.SynthesiseTagName` — both pure static helpers. There is no coverage for
|
||||||
@@ -213,7 +236,12 @@ driver CLIs.
|
|||||||
(`HostAddress`, `PlcFamily`, `DeviceName`), the supplied tag list, and the `Timeout`
|
(`HostAddress`, `PlcFamily`, `DeviceName`), the supplied tag list, and the `Timeout`
|
||||||
derived from `TimeoutMs`.
|
derived from `TimeoutMs`.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — added
|
||||||
|
`tests/.../AbCipCommandBaseTests.cs` covering `BuildOptions` (probe disabled,
|
||||||
|
controller-browse disabled, alarm-projection disabled, single device with
|
||||||
|
`HostAddress` / `PlcFamily` / `cli-{Family}` device name, tag list passed verbatim,
|
||||||
|
`Timeout` derived from `TimeoutMs`) and `DriverInstanceId` (`abcip-cli-{Gateway}`),
|
||||||
|
plus the `RejectStructure` guard (throws for `Structure`, no-op for atomic types).
|
||||||
|
|
||||||
### Driver.AbCip.Cli-008
|
### Driver.AbCip.Cli-008
|
||||||
|
|
||||||
@@ -222,7 +250,7 @@ derived from `TimeoutMs`.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Location | `docs/Driver.AbCip.Cli.md:8-9` |
|
| Location | `docs/Driver.AbCip.Cli.md:8-9` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `docs/Driver.AbCip.Cli.md` opens with "Second of four driver
|
**Description:** `docs/Driver.AbCip.Cli.md` opens with "Second of four driver
|
||||||
test-client CLIs (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT)." The count "four"
|
test-client CLIs (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT)." The count "four"
|
||||||
@@ -235,4 +263,6 @@ doc's "four" and the truncated chain are both stale.
|
|||||||
and complete the chain (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT -> FOCAS), or
|
and complete the chain (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT -> FOCAS), or
|
||||||
drop the explicit count and link `docs/DriverClis.md` as the authoritative roster.
|
drop the explicit count and link `docs/DriverClis.md` as the authoritative roster.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — rewrote the lead paragraph to "Second of six
|
||||||
|
driver test-client CLIs (Modbus -> AB CIP -> AB Legacy -> S7 -> TwinCAT -> FOCAS)"
|
||||||
|
and added a link to `docs/DriverClis.md` as the authoritative roster.
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ Ad-hoc probe / read / write / subscribe tool for ControlLogix / CompactLogix /
|
|||||||
Micro800 / GuardLogix PLCs, talking to the **same** `AbCipDriver` the OtOpcUa
|
Micro800 / GuardLogix PLCs, talking to the **same** `AbCipDriver` the OtOpcUa
|
||||||
server uses (libplctag under the hood).
|
server uses (libplctag under the hood).
|
||||||
|
|
||||||
Second of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
Second of six driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||||
TwinCAT). Shares `Driver.Cli.Common` with the others.
|
TwinCAT → FOCAS). Shares `Driver.Cli.Common` with the others; see
|
||||||
|
[DriverClis.md](DriverClis.md) for the authoritative roster.
|
||||||
|
|
||||||
## Build + run
|
## Build + run
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,28 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
|||||||
public int TimeoutMs { get; init; } = 5000;
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// The getter validates <see cref="TimeoutMs"/> (Driver.AbCip.Cli-004) — a zero or
|
||||||
|
/// negative <c>--timeout-ms</c> would otherwise propagate as a non-positive
|
||||||
|
/// <see cref="TimeSpan"/> into the driver. The <c>init</c> accessor is unreachable
|
||||||
|
/// because CliFx binds <see cref="TimeoutMs"/> rather than <c>Timeout</c>; it throws
|
||||||
|
/// <see cref="NotSupportedException"/> so an object-initializer assignment
|
||||||
|
/// (<c>new ReadCommand { Timeout = ... }</c>) fails fast instead of being silently
|
||||||
|
/// discarded (Driver.AbCip.Cli-006).
|
||||||
|
/// </remarks>
|
||||||
public override TimeSpan Timeout
|
public override TimeSpan Timeout
|
||||||
{
|
{
|
||||||
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
get
|
||||||
init { /* driven by TimeoutMs */ }
|
{
|
||||||
|
if (TimeoutMs <= 0)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"--timeout-ms must be > 0 (got {TimeoutMs}). " +
|
||||||
|
"Pick a positive number of milliseconds for the per-operation timeout.");
|
||||||
|
return TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
}
|
||||||
|
init => throw new NotSupportedException(
|
||||||
|
$"{nameof(AbCipCommandBase)}.{nameof(Timeout)} is derived from {nameof(TimeoutMs)} " +
|
||||||
|
"and cannot be assigned directly. Set TimeoutMs instead.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public sealed class ProbeCommand : AbCipCommandBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||||
|
// output emitted during driver shutdown is not lost.
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ public sealed class ReadCommand : AbCipCommandBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||||
|
// output emitted during driver shutdown is not lost.
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
|||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
RejectStructure(DataType);
|
RejectStructure(DataType);
|
||||||
|
ValidateInterval(IntervalMs);
|
||||||
|
// Touch Timeout to surface the --timeout-ms guard (Driver.AbCip.Cli-004) before
|
||||||
|
// we open a driver — fast-fail with a clean CommandException on bad operator input.
|
||||||
|
_ = Timeout;
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
||||||
@@ -48,6 +52,13 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
|||||||
{
|
{
|
||||||
await driver.InitializeAsync("{}", ct);
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
// Driver.AbCip.Cli-003 — emit the banner BEFORE wiring OnDataChange so the
|
||||||
|
// main-thread write cannot interleave with poll-thread change-event writes.
|
||||||
|
// TextWriter.WriteLine is not guaranteed thread-safe; once the handler is
|
||||||
|
// attached and SubscribeAsync starts, change events run on the poll thread.
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||||
|
|
||||||
driver.OnDataChange += (_, e) =>
|
driver.OnDataChange += (_, e) =>
|
||||||
{
|
{
|
||||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
@@ -58,8 +69,6 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
|||||||
|
|
||||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
|
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
@@ -77,6 +86,23 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
|||||||
catch { /* teardown best-effort */ }
|
catch { /* teardown best-effort */ }
|
||||||
}
|
}
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||||
|
// lines emitted just before Ctrl+C are not lost on abrupt termination.
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards <c>--interval-ms</c> against zero or negative values (Driver.AbCip.Cli-004).
|
||||||
|
/// A non-positive interval would produce a non-positive <see cref="TimeSpan"/> into
|
||||||
|
/// <c>SubscribeAsync</c>; the CLI should fail fast with an actionable error rather
|
||||||
|
/// than relying on the downstream <c>PollGroupEngine</c> to clamp the value.
|
||||||
|
/// </summary>
|
||||||
|
internal static void ValidateInterval(int intervalMs)
|
||||||
|
{
|
||||||
|
if (intervalMs <= 0)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"--interval-ms must be > 0 (got {intervalMs}). " +
|
||||||
|
"PollGroupEngine floors sub-250ms values, but accepts any positive integer.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ public sealed class WriteCommand : AbCipCommandBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
// Driver.AbCip.Cli-005 — flush Serilog before process exit so buffered log
|
||||||
|
// output emitted during driver shutdown is not lost.
|
||||||
|
FlushLogging();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="AbCipCommandBase"/>: the shared <c>BuildOptions</c> projection
|
||||||
|
/// (driver-options mapping the four commands depend on), the <c>RejectStructure</c>
|
||||||
|
/// guard, the <c>Timeout</c> override behaviour, and <c>TimeoutMs</c> validation.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipCommandBaseTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Local subclass that surfaces the protected helpers + properties under test.
|
||||||
|
/// </summary>
|
||||||
|
[CliFx.Attributes.Command("test")]
|
||||||
|
private sealed class TestableCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
public AbCipDriverOptions InvokeBuildOptions(IReadOnlyList<AbCipTagDefinition> tags)
|
||||||
|
=> BuildOptions(tags);
|
||||||
|
|
||||||
|
public string InvokeDriverInstanceId => DriverInstanceId;
|
||||||
|
|
||||||
|
public override ValueTask ExecuteAsync(CliFx.Infrastructure.IConsole console)
|
||||||
|
=> ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbCipTagDefinition SampleTag(string name = "Motor01") => new(
|
||||||
|
Name: name,
|
||||||
|
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||||
|
TagPath: "Motor01",
|
||||||
|
DataType: AbCipDataType.DInt,
|
||||||
|
Writable: false);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_disables_probe_so_cli_does_not_race_operator_reads()
|
||||||
|
{
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Family = AbCipPlcFamily.ControlLogix,
|
||||||
|
TimeoutMs = 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = cmd.InvokeBuildOptions([SampleTag()]);
|
||||||
|
|
||||||
|
options.Probe.Enabled.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_disables_controller_browse()
|
||||||
|
{
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Family = AbCipPlcFamily.ControlLogix,
|
||||||
|
TimeoutMs = 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = cmd.InvokeBuildOptions([SampleTag()]);
|
||||||
|
|
||||||
|
options.EnableControllerBrowse.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_disables_alarm_projection()
|
||||||
|
{
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Family = AbCipPlcFamily.ControlLogix,
|
||||||
|
TimeoutMs = 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = cmd.InvokeBuildOptions([SampleTag()]);
|
||||||
|
|
||||||
|
options.EnableAlarmProjection.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_produces_one_device_with_gateway_family_and_derived_name()
|
||||||
|
{
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Family = AbCipPlcFamily.CompactLogix,
|
||||||
|
TimeoutMs = 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = cmd.InvokeBuildOptions([SampleTag()]);
|
||||||
|
|
||||||
|
options.Devices.Count.ShouldBe(1);
|
||||||
|
var device = options.Devices[0];
|
||||||
|
device.HostAddress.ShouldBe("ab://10.0.0.5/1,0");
|
||||||
|
device.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
|
||||||
|
device.DeviceName.ShouldBe("cli-CompactLogix");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_passes_supplied_tag_list_verbatim()
|
||||||
|
{
|
||||||
|
var tags = new[] { SampleTag("t1"), SampleTag("t2") };
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Family = AbCipPlcFamily.ControlLogix,
|
||||||
|
TimeoutMs = 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = cmd.InvokeBuildOptions(tags);
|
||||||
|
|
||||||
|
options.Tags.Count.ShouldBe(2);
|
||||||
|
options.Tags[0].Name.ShouldBe("t1");
|
||||||
|
options.Tags[1].Name.ShouldBe("t2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_carries_TimeoutMs_through_to_Timeout()
|
||||||
|
{
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Family = AbCipPlcFamily.ControlLogix,
|
||||||
|
TimeoutMs = 7500,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = cmd.InvokeBuildOptions([SampleTag()]);
|
||||||
|
|
||||||
|
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriverInstanceId_embeds_gateway_for_log_disambiguation()
|
||||||
|
{
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Family = AbCipPlcFamily.ControlLogix,
|
||||||
|
};
|
||||||
|
|
||||||
|
cmd.InvokeDriverInstanceId.ShouldBe("abcip-cli-ab://10.0.0.5/1,0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Timeout_setter_is_inert_and_does_not_silently_swallow_assignments()
|
||||||
|
{
|
||||||
|
// Driver.AbCip.Cli-006 — the empty init body would silently discard an
|
||||||
|
// object-initializer assignment, hiding a "driven by TimeoutMs" misuse. The fix
|
||||||
|
// makes it fail-fast with NotSupportedException so the contract is explicit.
|
||||||
|
Should.Throw<NotSupportedException>(() => new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
Timeout = TimeSpan.FromSeconds(99),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
public void Timeout_get_throws_CommandException_when_TimeoutMs_is_non_positive(int badMs)
|
||||||
|
{
|
||||||
|
// Driver.AbCip.Cli-004 — TimeoutMs must be > 0. Validation is exposed via the
|
||||||
|
// Timeout getter so any command path that touches Timeout sees the same guard.
|
||||||
|
var cmd = new TestableCommand
|
||||||
|
{
|
||||||
|
Gateway = "ab://10.0.0.5/1,0",
|
||||||
|
TimeoutMs = badMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => _ = cmd.Timeout);
|
||||||
|
ex.Message.ShouldContain("--timeout-ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectStructure_throws_for_Structure_DataType()
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => CallRejectStructure(AbCipDataType.Structure));
|
||||||
|
ex.Message.ShouldContain("Structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbCipDataType.DInt)]
|
||||||
|
[InlineData(AbCipDataType.Bool)]
|
||||||
|
[InlineData(AbCipDataType.Real)]
|
||||||
|
public void RejectStructure_passes_for_atomic_types(AbCipDataType type)
|
||||||
|
{
|
||||||
|
// No throw — atomic types are allowed.
|
||||||
|
Should.NotThrow(() => CallRejectStructure(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The static helper is protected; reflect to it once so the test stays at AbCipCommandBase.
|
||||||
|
private static void CallRejectStructure(AbCipDataType type)
|
||||||
|
{
|
||||||
|
var method = typeof(AbCipCommandBase).GetMethod(
|
||||||
|
"RejectStructure",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
|
||||||
|
?? throw new InvalidOperationException("RejectStructure not found");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
method.Invoke(null, [type]);
|
||||||
|
}
|
||||||
|
catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null)
|
||||||
|
{
|
||||||
|
throw tie.InnerException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="SubscribeCommand.ValidateInterval(int)"/> — the guard that
|
||||||
|
/// stops a zero / negative <c>--interval-ms</c> from reaching <c>SubscribeAsync</c>
|
||||||
|
/// as a non-positive <see cref="TimeSpan"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class SubscribeCommandIntervalTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(-500)]
|
||||||
|
public void ValidateInterval_rejects_non_positive(int badMs)
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => SubscribeCommand.ValidateInterval(badMs));
|
||||||
|
ex.Message.ShouldContain("--interval-ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(250)]
|
||||||
|
[InlineData(60_000)]
|
||||||
|
public void ValidateInterval_accepts_positive(int goodMs)
|
||||||
|
{
|
||||||
|
Should.NotThrow(() => SubscribeCommand.ValidateInterval(goodMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user