fix(driver-focas-cli): resolve Low code-review findings (Driver.FOCAS.Cli-001,002,003,004; -005 deferred)
- Driver.FOCAS.Cli-001: WriteCommand.ParseValue now wraps numeric FormatException / OverflowException as CliFx CommandException with the offending value. - Driver.FOCAS.Cli-002: SubscribeCommand's OnDataChange handler and the banner both take a writeLock so notification-callback and main-thread writes can't interleave; handler exceptions are warn-and-swallow. - Driver.FOCAS.Cli-003: FocasCommandBase.ValidateOptions rejects --cnc-port outside 1..65535, non-positive --timeout-ms, and non-positive --interval-ms; ExecuteAsync calls it first. - Driver.FOCAS.Cli-004: 'await using var driver' is the sole driver disposal path; dropped the redundant explicit await ShutdownAsync. - Driver.FOCAS.Cli-005 (Deferred): the fix lives in Driver.Cli.Common.SnapshotFormatter — explicitly naming the status-code shortlist there benefits every driver CLI. Left as a Driver.Cli.Common follow-up. - Registered the new tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests project in ZB.MOM.WW.OtOpcUa.slnx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,7 @@
|
|||||||
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj" />
|
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj" />
|
||||||
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj" />
|
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj" />
|
||||||
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj" />
|
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj" />
|
||||||
|
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/Client/">
|
<Folder Name="/tests/Client/">
|
||||||
<Project Path="tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj" />
|
<Project Path="tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj" />
|
||||||
|
|||||||
@@ -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 | 5 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ a category produced nothing rather than leaving it blank.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `Commands/WriteCommand.cs:58-68` |
|
| Location | `Commands/WriteCommand.cs:58-68` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `WriteCommand.ParseValue` parses the numeric `--value` types
|
**Description:** `WriteCommand.ParseValue` parses the numeric `--value` types
|
||||||
(`Byte`/`Int16`/`Int32`/`Float32`/`Float64`) with `sbyte.Parse` / `short.Parse`
|
(`Byte`/`Int16`/`Int32`/`Float32`/`Float64`) with `sbyte.Parse` / `short.Parse`
|
||||||
@@ -65,7 +65,16 @@ literal — consistent with how `ParseBool` already handles bad boolean input.
|
|||||||
The same pattern exists in the sibling S7 CLI; a shared helper in
|
The same pattern exists in the sibling S7 CLI; a shared helper in
|
||||||
`Driver.Cli.Common` would fix both.
|
`Driver.Cli.Common` would fix both.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — wrapped the `ParseValue` numeric switch in
|
||||||
|
`try/catch (FormatException)` and `try/catch (OverflowException)` that rethrow as
|
||||||
|
`CliFx.Exceptions.CommandException` with a message naming the `--type` and the
|
||||||
|
offending value, mirroring the friendly text the `Bit` path already produced.
|
||||||
|
Added `WriteCommandParseValueTests` with [Theory] cases covering non-numeric
|
||||||
|
input across `Byte`/`Int16`/`Int32`/`Float32`/`Float64`, overflow edges
|
||||||
|
(sbyte ±1, short max+1, > int.MaxValue), and an assertion that the exception
|
||||||
|
message names both the type and the offending value. A shared `Driver.Cli.Common`
|
||||||
|
helper is the cleaner long-term fix (cross-CLI duplication remains) but is left
|
||||||
|
to the Driver.Cli.Common review per this module's edit scope.
|
||||||
|
|
||||||
### Driver.FOCAS.Cli-002
|
### Driver.FOCAS.Cli-002
|
||||||
|
|
||||||
@@ -74,7 +83,7 @@ The same pattern exists in the sibling S7 CLI; a shared helper in
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `Commands/SubscribeCommand.cs:45-51` |
|
| Location | `Commands/SubscribeCommand.cs:45-51` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The `subscribe` command attaches an `OnDataChange` handler that
|
**Description:** The `subscribe` command attaches an `OnDataChange` handler that
|
||||||
calls the synchronous `console.Output.WriteLine`. `OnDataChange` is raised from
|
calls the synchronous `console.Output.WriteLine`. `OnDataChange` is raised from
|
||||||
@@ -93,7 +102,15 @@ console writes with a lock shared between the banner and the handler. Optionally
|
|||||||
detach the handler in the `finally` block before `ShutdownAsync` for symmetry
|
detach the handler in the `finally` block before `ShutdownAsync` for symmetry
|
||||||
with the `handle` teardown already present there.
|
with the `handle` teardown already present there.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — introduced a `writeLock` shared between the
|
||||||
|
`OnDataChange` handler and the banner write so the poll-engine background thread
|
||||||
|
and the CliFx invocation thread can't interleave partial lines. Added an
|
||||||
|
explanatory comment above the handler explaining the CliFx-`IConsole` rationale
|
||||||
|
and the synchronous-on-background-thread design — mirroring the Modbus / S7
|
||||||
|
copies of this command. Also added a try/catch around the handler body so a
|
||||||
|
transient stdout error cannot tear down the poll loop, and Serilog-warn-logs the
|
||||||
|
swallowed exception. Added `SubscribeCommandConsoleHandlerTests` to guard the
|
||||||
|
`writeLock` + CliFx-`IConsole` rationale against future copy-paste regressions.
|
||||||
|
|
||||||
### Driver.FOCAS.Cli-003
|
### Driver.FOCAS.Cli-003
|
||||||
|
|
||||||
@@ -102,7 +119,7 @@ with the `handle` teardown already present there.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `FocasCommandBase.cs:19` (`CncPort`), `FocasCommandBase.cs:27` (`TimeoutMs`), `Commands/SubscribeCommand.cs:23` (`IntervalMs`) |
|
| Location | `FocasCommandBase.cs:19` (`CncPort`), `FocasCommandBase.cs:27` (`TimeoutMs`), `Commands/SubscribeCommand.cs:23` (`IntervalMs`) |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The numeric command options `--cnc-port`, `--timeout-ms`, and
|
**Description:** The numeric command options `--cnc-port`, `--timeout-ms`, and
|
||||||
`--interval-ms` are accepted without range validation. A zero or negative
|
`--interval-ms` are accepted without range validation. A zero or negative
|
||||||
@@ -120,7 +137,17 @@ timeout and interval strictly positive. The same gap exists across the sibling
|
|||||||
driver CLIs, so a shared validation helper in `Driver.Cli.Common` is the
|
driver CLIs, so a shared validation helper in `Driver.Cli.Common` is the
|
||||||
cleaner fix.
|
cleaner fix.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — added a protected `ValidateOptions(int?
|
||||||
|
intervalMs = null)` helper on `FocasCommandBase` that rejects `--cnc-port`
|
||||||
|
outside `1..65535`, non-positive `--timeout-ms`, and non-positive
|
||||||
|
`--interval-ms` (when the caller passes one) with a `CliFx.Exceptions.CommandException`
|
||||||
|
naming the option and the rejected value. `ProbeCommand` / `ReadCommand` /
|
||||||
|
`WriteCommand` call `ValidateOptions()` without an interval, `SubscribeCommand`
|
||||||
|
calls `ValidateOptions(IntervalMs)`. Added `FocasCommandBaseValidationTests`
|
||||||
|
covering accept-defaults, reject out-of-range port (0, -1, 65536), reject
|
||||||
|
non-positive timeout / interval, and skip-interval-when-omitted. A shared
|
||||||
|
helper in `Driver.Cli.Common` is the cleaner cross-CLI fix and is recorded
|
||||||
|
against that module's review.
|
||||||
|
|
||||||
### Driver.FOCAS.Cli-004
|
### Driver.FOCAS.Cli-004
|
||||||
|
|
||||||
@@ -129,7 +156,7 @@ cleaner fix.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Location | `Commands/ProbeCommand.cs:37,54`; `Commands/ReadCommand.cs:37,46`; `Commands/WriteCommand.cs:45,54`; `Commands/SubscribeCommand.cs:39,73` |
|
| Location | `Commands/ProbeCommand.cs:37,54`; `Commands/ReadCommand.cs:37,46`; `Commands/WriteCommand.cs:45,54`; `Commands/SubscribeCommand.cs:39,73` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Every command declares `await using var driver = new FocasDriver(...)`
|
**Description:** Every command declares `await using var driver = new FocasDriver(...)`
|
||||||
**and** explicitly calls `await driver.ShutdownAsync(CancellationToken.None)` in
|
**and** explicitly calls `await driver.ShutdownAsync(CancellationToken.None)` in
|
||||||
@@ -144,7 +171,14 @@ dead weight and obscures intent: a reader cannot tell whether the explicit
|
|||||||
and rely on `await using` for disposal, or drop `await using` and keep the
|
and rely on `await using` for disposal, or drop `await using` and keep the
|
||||||
explicit teardown — but not both. The same redundancy exists in the sibling CLIs.
|
explicit teardown — but not both. The same redundancy exists in the sibling CLIs.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Resolved 2026-05-23 — dropped the explicit
|
||||||
|
`await driver.ShutdownAsync(CancellationToken.None)` calls from the `finally`
|
||||||
|
blocks of `ProbeCommand`, `ReadCommand`, `WriteCommand`, and `SubscribeCommand`;
|
||||||
|
`await using` is now the sole driver-disposal mechanism per command
|
||||||
|
(`FocasDriver.DisposeAsync` itself runs `ShutdownAsync`). The subscribe command
|
||||||
|
keeps `UnsubscribeAsync` in its finally because that is a subscription-lifecycle
|
||||||
|
concern, not driver disposal. Added `CommandDisposalConventionsTests` to guard
|
||||||
|
the source-level convention against regression.
|
||||||
|
|
||||||
### Driver.FOCAS.Cli-005
|
### Driver.FOCAS.Cli-005
|
||||||
|
|
||||||
@@ -153,7 +187,7 @@ explicit teardown — but not both. The same redundancy exists in the sibling CL
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `Commands/WriteCommand.cs:50`, `Commands/ProbeCommand.cs:50` (via `SnapshotFormatter.FormatStatus`) |
|
| Location | `Commands/WriteCommand.cs:50`, `Commands/ProbeCommand.cs:50` (via `SnapshotFormatter.FormatStatus`) |
|
||||||
| Status | Open |
|
| Status | Deferred |
|
||||||
|
|
||||||
**Description:** `docs/Driver.FOCAS.Cli.md` documents `BadDeviceFailure` and
|
**Description:** `docs/Driver.FOCAS.Cli.md` documents `BadDeviceFailure` and
|
||||||
`BadCommunicationError` as the key diagnostic signals an operator reads off
|
`BadCommunicationError` as the key diagnostic signals an operator reads off
|
||||||
@@ -180,4 +214,14 @@ actually emit — at minimum `BadNotWritable`, `BadOutOfRange`, `BadNotSupported
|
|||||||
because the gap defeats this module documented `probe`/`write` diagnostic
|
because the gap defeats this module documented `probe`/`write` diagnostic
|
||||||
workflow; cross-reference the `Driver.Cli.Common` review.
|
workflow; cross-reference the `Driver.Cli.Common` review.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**Resolution:** Deferred 2026-05-23 — the recommended fix lives in
|
||||||
|
`SnapshotFormatter.FormatStatus` inside the `Driver.Cli.Common` shared module,
|
||||||
|
which is outside this module's edit scope. Driver.Cli.Common-001 / -002 have
|
||||||
|
already corrected the existing shortlist mappings and added a severity-class
|
||||||
|
fallback so the FOCAS-emitted codes now at least render with a "Bad" /
|
||||||
|
"Uncertain" / "Good" suffix rather than bare hex; explicitly naming
|
||||||
|
`BadNotWritable`, `BadOutOfRange`, `BadNotSupported`, `BadDeviceFailure`,
|
||||||
|
`BadInternalError`, and the canonical `BadTimeout` (0x800A0000) belongs to
|
||||||
|
the Driver.Cli.Common review's follow-up (and benefits every driver CLI, not
|
||||||
|
just FOCAS). Re-open here only if Driver.Cli.Common declines to extend the
|
||||||
|
shortlist.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public sealed class ProbeCommand : FocasCommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
// Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work.
|
||||||
|
ValidateOptions();
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var probeTag = new FocasTagDefinition(
|
var probeTag = new FocasTagDefinition(
|
||||||
@@ -34,24 +36,20 @@ public sealed class ProbeCommand : FocasCommandBase
|
|||||||
Writable: false);
|
Writable: false);
|
||||||
var options = BuildOptions([probeTag]);
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
// Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
|
||||||
|
// 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 FocasDriver(options, DriverInstanceId);
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||||
try
|
await driver.InitializeAsync("{}", ct);
|
||||||
{
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
await driver.InitializeAsync("{}", ct);
|
var health = driver.GetHealth();
|
||||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
|
||||||
var health = driver.GetHealth();
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"CNC: {CncHost}:{CncPort}");
|
await console.Output.WriteLineAsync($"CNC: {CncHost}:{CncPort}");
|
||||||
await console.Output.WriteLineAsync($"Series: {Series}");
|
await console.Output.WriteLineAsync($"Series: {Series}");
|
||||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||||
if (health.LastError is { } err)
|
if (health.LastError is { } err)
|
||||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||||
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]));
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class ReadCommand : FocasCommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
// Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work.
|
||||||
|
ValidateOptions();
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var tagName = SynthesiseTagName(Address, DataType);
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
@@ -34,17 +36,13 @@ public sealed class ReadCommand : FocasCommandBase
|
|||||||
Writable: false);
|
Writable: false);
|
||||||
var options = BuildOptions([tag]);
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
// Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
|
||||||
|
// 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 FocasDriver(options, DriverInstanceId);
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||||
try
|
await driver.InitializeAsync("{}", ct);
|
||||||
{
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
await driver.InitializeAsync("{}", ct);
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
|
||||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string SynthesiseTagName(string address, FocasDataType type)
|
internal static string SynthesiseTagName(string address, FocasDataType type)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public sealed class SubscribeCommand : FocasCommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
// Driver.FOCAS.Cli-003: validate numeric option ranges (including the subscribe-only
|
||||||
|
// --interval-ms) before any driver work so a zero/negative interval surfaces as a
|
||||||
|
// clean CommandException rather than a tight-spinning poll loop.
|
||||||
|
ValidateOptions(IntervalMs);
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
@@ -36,24 +40,59 @@ public sealed class SubscribeCommand : FocasCommandBase
|
|||||||
Writable: false);
|
Writable: false);
|
||||||
var options = BuildOptions([tag]);
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
// Driver.FOCAS.Cli-004: `await using` is the sole driver-disposal mechanism — FocasDriver.DisposeAsync
|
||||||
|
// already invokes ShutdownAsync, so a redundant ShutdownAsync(CancellationToken.None) in finally
|
||||||
|
// 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 FocasDriver(options, DriverInstanceId);
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||||
|
// Driver.FOCAS.Cli-002: serialize console writes from the PollGroupEngine background
|
||||||
|
// thread so overlapping poll ticks (and the "Subscribed to ..." banner from the CliFx
|
||||||
|
// invocation thread) can't interleave partial lines.
|
||||||
|
var writeLock = new object();
|
||||||
ISubscriptionHandle? handle = null;
|
ISubscriptionHandle? handle = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await driver.InitializeAsync("{}", ct);
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
// Driver.FOCAS.Cli-002: route every data-change event to the CliFx console (not
|
||||||
|
// System.Console — the analyzer flags it + IConsole is the testable abstraction).
|
||||||
|
// The handler is synchronous because OnDataChange is raised from a driver
|
||||||
|
// background thread; the IConsole.Output writer is not documented as thread-safe
|
||||||
|
// so we serialize against the banner write via writeLock.
|
||||||
driver.OnDataChange += (_, e) =>
|
driver.OnDataChange += (_, e) =>
|
||||||
{
|
{
|
||||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
// Swallow + log write failures so a transient stdout error (closed pipe, IO
|
||||||
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
// exception on a redirected stream) cannot tear down the poll-engine
|
||||||
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
// background loop. Without this guard the unhandled exception would fault
|
||||||
console.Output.WriteLine(line);
|
// the long-running subscribe.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
|
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||||
|
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||||
|
lock (writeLock)
|
||||||
|
{
|
||||||
|
console.Output.WriteLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Warning(ex,
|
||||||
|
"SubscribeCommand: console write failed for {Tag}; continuing poll loop.",
|
||||||
|
e.FullReference);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
|
|
||||||
await console.Output.WriteLineAsync(
|
// Driver.FOCAS.Cli-002: hold the lock around the banner write so the first
|
||||||
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
|
// poll-driven change line from the driver tick thread can't interleave with
|
||||||
|
// this banner.
|
||||||
|
lock (writeLock)
|
||||||
|
{
|
||||||
|
console.Output.WriteLine(
|
||||||
|
$"Subscribed to {Address} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
@@ -67,10 +106,16 @@ public sealed class SubscribeCommand : FocasCommandBase
|
|||||||
{
|
{
|
||||||
if (handle is not null)
|
if (handle is not null)
|
||||||
{
|
{
|
||||||
|
// Driver.FOCAS.Cli-002: detach the OnDataChange handler before unsubscribe +
|
||||||
|
// disposal for symmetry with the handle teardown, so a future refactor that
|
||||||
|
// reuses the driver after the subscribe verb returns wouldn't leak a
|
||||||
|
// dangling subscription.
|
||||||
|
// (Single anonymous handler instance is captured implicitly by `await using`
|
||||||
|
// disposing the driver immediately afterwards; the unsubscribe + dispose
|
||||||
|
// sequence is what really cleans up here.)
|
||||||
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
catch { /* teardown best-effort */ }
|
catch { /* teardown best-effort */ }
|
||||||
}
|
}
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ public sealed class WriteCommand : FocasCommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
ConfigureLogging();
|
ConfigureLogging();
|
||||||
|
// Driver.FOCAS.Cli-003: validate numeric option ranges before any driver work so
|
||||||
|
// a zero/negative port/timeout surfaces as a clean CommandException rather than an
|
||||||
|
// opaque downstream exception.
|
||||||
|
ValidateOptions();
|
||||||
var ct = console.RegisterCancellationHandler();
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
@@ -42,30 +46,49 @@ public sealed class WriteCommand : FocasCommandBase
|
|||||||
|
|
||||||
var parsed = ParseValue(Value, DataType);
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
// Driver.FOCAS.Cli-004: `await using` is the sole disposal mechanism — FocasDriver.DisposeAsync
|
||||||
|
// 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 FocasDriver(options, DriverInstanceId);
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||||
try
|
await driver.InitializeAsync("{}", ct);
|
||||||
{
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
await driver.InitializeAsync("{}", ct);
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
|
||||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await driver.ShutdownAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static object ParseValue(string raw, FocasDataType type) => type switch
|
/// <summary>Parse <c>--value</c> per <see cref="FocasDataType"/>, invariant culture throughout.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Driver.FOCAS.Cli-001: numeric parses are wrapped so that malformed input
|
||||||
|
/// (<see cref="FormatException"/> / <see cref="OverflowException"/>) surfaces
|
||||||
|
/// as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than a raw
|
||||||
|
/// .NET stack trace — matching the friendly message the Bit path already produces.
|
||||||
|
/// </remarks>
|
||||||
|
internal static object ParseValue(string raw, FocasDataType type)
|
||||||
{
|
{
|
||||||
FocasDataType.Bit => ParseBool(raw),
|
if (type == FocasDataType.Bit) return ParseBool(raw);
|
||||||
FocasDataType.Byte => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
if (type == FocasDataType.String) return raw;
|
||||||
FocasDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
try
|
||||||
FocasDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
{
|
||||||
FocasDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
return type switch
|
||||||
FocasDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
{
|
||||||
FocasDataType.String => raw,
|
FocasDataType.Byte => (object)sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
FocasDataType.Int16 => (object)short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
};
|
FocasDataType.Int32 => (object)int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
FocasDataType.Float32 => (object)float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
FocasDataType.Float64 => (object)double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (FormatException ex)
|
||||||
|
{
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Value '{raw}' is not a valid {type}: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (OverflowException ex)
|
||||||
|
{
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Value '{raw}' is out of range for {type}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,4 +54,26 @@ public abstract class FocasCommandBase : DriverCommandBase
|
|||||||
};
|
};
|
||||||
|
|
||||||
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.FOCAS.Cli-003: validate numeric option ranges at the CLI boundary so a
|
||||||
|
/// zero/negative <c>--cnc-port</c>, <c>--timeout-ms</c>, or <c>--interval-ms</c>
|
||||||
|
/// surfaces as a clean <see cref="CliFx.Exceptions.CommandException"/> rather than
|
||||||
|
/// either an opaque downstream exception (invalid <c>focas://host:<n></c> /
|
||||||
|
/// zero <c>TimeSpan</c>) or a tight-spinning poll loop. The <c>--interval-ms</c>
|
||||||
|
/// option is subscribe-only — pass <c>null</c> for probe/read/write so this
|
||||||
|
/// helper can be a single shared validator.
|
||||||
|
/// </summary>
|
||||||
|
protected void ValidateOptions(int? intervalMs = null)
|
||||||
|
{
|
||||||
|
if (CncPort < 1 || CncPort > 65535)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"--cnc-port must be in the range 1..65535 (got {CncPort}).");
|
||||||
|
if (TimeoutMs <= 0)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"--timeout-ms must be positive (got {TimeoutMs}).");
|
||||||
|
if (intervalMs is { } iv && iv <= 0)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"--interval-ms must be positive (got {iv}).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
<ProjectReference Include="..\..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
<ProjectReference Include="..\..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- CLI runs the managed WireFocasClient and talks to the CNC over TCP:8193
|
<!-- CLI runs the managed WireFocasClient and talks to the CNC over TCP:8193
|
||||||
directly — no Fwlib64.dll copy step needed. -->
|
directly — no Fwlib64.dll copy step needed. -->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.FOCAS.Cli-004: every FOCAS CLI command must own one disposal mechanism for
|
||||||
|
/// the <c>FocasDriver</c>, not two. The chosen mechanism is <c>await using var driver
|
||||||
|
/// = ...</c> — <c>FocasDriver.DisposeAsync</c> already calls <c>ShutdownAsync</c>, so
|
||||||
|
/// an additional explicit <c>driver.ShutdownAsync(...)</c> in a <c>finally</c> block
|
||||||
|
/// runs shutdown twice. These tests guard against that regression by scanning the
|
||||||
|
/// command source files.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class CommandDisposalConventionsTests
|
||||||
|
{
|
||||||
|
private static readonly string CommandsDir = LocateCommandsDir();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ProbeCommand.cs")]
|
||||||
|
[InlineData("ReadCommand.cs")]
|
||||||
|
[InlineData("WriteCommand.cs")]
|
||||||
|
[InlineData("SubscribeCommand.cs")]
|
||||||
|
public void Command_does_not_call_ShutdownAsync_explicitly(string commandFile)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(CommandsDir, commandFile);
|
||||||
|
File.Exists(path).ShouldBeTrue($"Expected {path} to exist.");
|
||||||
|
var source = File.ReadAllText(path);
|
||||||
|
|
||||||
|
// The await-using statement is the single disposal mechanism. An explicit
|
||||||
|
// driver.ShutdownAsync(...) call (typically inside a finally block) re-invokes
|
||||||
|
// a shutdown path that DisposeAsync already runs and is the smell -004 flags.
|
||||||
|
source.ShouldNotContain("driver.ShutdownAsync(");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ProbeCommand.cs")]
|
||||||
|
[InlineData("ReadCommand.cs")]
|
||||||
|
[InlineData("WriteCommand.cs")]
|
||||||
|
[InlineData("SubscribeCommand.cs")]
|
||||||
|
public void Command_uses_await_using_for_FocasDriver(string commandFile)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(CommandsDir, commandFile);
|
||||||
|
var source = File.ReadAllText(path);
|
||||||
|
|
||||||
|
source.ShouldContain("await using var driver = new FocasDriver(");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateCommandsDir()
|
||||||
|
{
|
||||||
|
// Walk up from the test assembly bin/ folder to the repo root, then into the
|
||||||
|
// source project's Commands/ directory. The test-host puts CWD somewhere under
|
||||||
|
// bin/Debug/net10.0 so we resolve relative to AppContext.BaseDirectory.
|
||||||
|
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.FOCAS.Cli", "Commands");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="FocasCommandBase.BuildOptions"/> — the pure, deterministic mapping
|
||||||
|
/// from the base's CNC host/port/series/timeout flags onto a
|
||||||
|
/// <c>FocasDriverOptions</c>. The CLI is one-shot so the background connectivity probe
|
||||||
|
/// must be disabled.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasCommandBaseBuildOptionsTests
|
||||||
|
{
|
||||||
|
[Command("noop-test", Description = "Test-only probe of FocasCommandBase.BuildOptions.")]
|
||||||
|
private sealed class ProbeOnly : FocasCommandBase
|
||||||
|
{
|
||||||
|
public override ValueTask ExecuteAsync(IConsole console) => default;
|
||||||
|
public FocasDriverOptions Invoke(IReadOnlyList<FocasTagDefinition> tags) => BuildOptions(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_disables_probe_for_one_shot_cli_runs()
|
||||||
|
{
|
||||||
|
var sut = new ProbeOnly
|
||||||
|
{
|
||||||
|
CncHost = "10.0.0.5",
|
||||||
|
CncPort = 8193,
|
||||||
|
Series = FocasCncSeries.ThirtyOne_i,
|
||||||
|
TimeoutMs = 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = sut.Invoke([]);
|
||||||
|
|
||||||
|
options.Probe.ShouldNotBeNull();
|
||||||
|
options.Probe.Enabled.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
|
||||||
|
{
|
||||||
|
var sut = new ProbeOnly { CncHost = "h", TimeoutMs = 7500 };
|
||||||
|
|
||||||
|
var options = sut.Invoke([]);
|
||||||
|
|
||||||
|
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_flows_host_port_series_through()
|
||||||
|
{
|
||||||
|
var sut = new ProbeOnly
|
||||||
|
{
|
||||||
|
CncHost = "cnc.shop.local",
|
||||||
|
CncPort = 8194,
|
||||||
|
Series = FocasCncSeries.Zero_i_F,
|
||||||
|
TimeoutMs = 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = sut.Invoke([]);
|
||||||
|
|
||||||
|
options.Devices.Count.ShouldBe(1);
|
||||||
|
options.Devices[0].HostAddress.ShouldBe("focas://cnc.shop.local:8194");
|
||||||
|
options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildOptions_forwards_tag_list_verbatim()
|
||||||
|
{
|
||||||
|
var sut = new ProbeOnly { CncHost = "h" };
|
||||||
|
var tag = new FocasTagDefinition(
|
||||||
|
Name: "t",
|
||||||
|
DeviceHostAddress: "focas://h:8193",
|
||||||
|
Address: "R100",
|
||||||
|
DataType: FocasDataType.Int16,
|
||||||
|
Writable: false);
|
||||||
|
|
||||||
|
var options = sut.Invoke([tag]);
|
||||||
|
|
||||||
|
options.Tags.Count.ShouldBe(1);
|
||||||
|
options.Tags[0].ShouldBeSameAs(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.FOCAS.Cli-003: numeric options that are not range-checked at the CLI
|
||||||
|
/// boundary surface either as opaque downstream exceptions or as tight-spinning
|
||||||
|
/// poll loops rather than a clear "value must be positive" message. These tests
|
||||||
|
/// pin the validation contract for <c>--cnc-port</c>, <c>--timeout-ms</c>, and
|
||||||
|
/// <c>--interval-ms</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasCommandBaseValidationTests
|
||||||
|
{
|
||||||
|
// Test-only FocasCommandBase concrete subclass that exposes the protected ValidateOptions
|
||||||
|
// helper. The [Command] attribute is required by the CliFx analyzer
|
||||||
|
// (CliFx_CommandMustBeAnnotated) — this command is never registered with the CLI app
|
||||||
|
// but the analyzer rule fires for every ICommand implementor in the compilation.
|
||||||
|
[Command("noop-test", Description = "Test-only probe of FocasCommandBase.ValidateOptions.")]
|
||||||
|
private sealed class Probe : FocasCommandBase
|
||||||
|
{
|
||||||
|
public int IntervalMs { get; init; }
|
||||||
|
|
||||||
|
public override ValueTask ExecuteAsync(IConsole console) => default;
|
||||||
|
|
||||||
|
public void InvokeValidate() => ValidateOptions(IntervalMs);
|
||||||
|
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_accepts_default_options()
|
||||||
|
{
|
||||||
|
var sut = new Probe { CncHost = "host", IntervalMs = 1000 };
|
||||||
|
Should.NotThrow(() => sut.InvokeValidate());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(65536)]
|
||||||
|
public void Validate_rejects_out_of_range_cnc_port(int port)
|
||||||
|
{
|
||||||
|
var sut = new Probe { CncHost = "host", CncPort = port, IntervalMs = 1000 };
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
|
||||||
|
ex.Message.ShouldContain("cnc-port", Case.Insensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-100)]
|
||||||
|
public void Validate_rejects_non_positive_timeout_ms(int timeoutMs)
|
||||||
|
{
|
||||||
|
var sut = new Probe { CncHost = "host", TimeoutMs = timeoutMs, IntervalMs = 1000 };
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
|
||||||
|
ex.Message.ShouldContain("timeout-ms", Case.Insensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-500)]
|
||||||
|
public void Validate_rejects_non_positive_interval_ms(int intervalMs)
|
||||||
|
{
|
||||||
|
var sut = new Probe { CncHost = "host", IntervalMs = intervalMs };
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
|
||||||
|
ex.Message.ShouldContain("interval-ms", Case.Insensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_skips_interval_check_when_command_omits_it()
|
||||||
|
{
|
||||||
|
// probe / read / write don't take an --interval-ms option; the validator must
|
||||||
|
// skip that check when the caller passes null.
|
||||||
|
var sut = new Probe { CncHost = "host" };
|
||||||
|
Should.NotThrow(() => sut.InvokeValidateNoInterval());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver.FOCAS.Cli-002: the FOCAS <c>subscribe</c> command — a near-verbatim copy
|
||||||
|
/// of the Modbus subscribe command — must
|
||||||
|
/// (a) serialise writes from the <c>OnDataChange</c> handler (raised from the
|
||||||
|
/// driver's <c>PollGroupEngine</c> background thread) with a lock, so the
|
||||||
|
/// "Subscribed to ..." banner write from the CliFx main thread cannot interleave
|
||||||
|
/// with the first poll-driven change line; and
|
||||||
|
/// (b) carry the explanatory comment that documents why <c>OnDataChange</c> uses
|
||||||
|
/// <c>console.Output.WriteLine</c> (synchronous, on a driver background thread)
|
||||||
|
/// instead of <c>System.Console</c> or the async <c>WriteLineAsync</c>. The
|
||||||
|
/// rationale is non-obvious to a reader and the Modbus copy carries it; the FOCAS
|
||||||
|
/// copy must too.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class SubscribeCommandConsoleHandlerTests
|
||||||
|
{
|
||||||
|
private static string ReadSubscribeSource()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
return File.ReadAllText(Path.Combine(
|
||||||
|
dir!.FullName, "src", "Drivers", "Cli", "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli",
|
||||||
|
"Commands", "SubscribeCommand.cs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeCommand_explains_why_OnDataChange_uses_console_Output_synchronously()
|
||||||
|
{
|
||||||
|
var source = ReadSubscribeSource();
|
||||||
|
|
||||||
|
// The comment must reference the CliFx console abstraction so future copy-pastes
|
||||||
|
// do not lose the rationale.
|
||||||
|
source.ShouldContain("CliFx console");
|
||||||
|
source.ShouldContain("IConsole");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeCommand_serialises_console_writes_with_a_lock()
|
||||||
|
{
|
||||||
|
var source = ReadSubscribeSource();
|
||||||
|
|
||||||
|
// Both the banner write and the OnDataChange handler must share a writeLock so the
|
||||||
|
// banner from the CliFx invocation thread cannot interleave with the first
|
||||||
|
// poll-driven change line from the driver tick thread.
|
||||||
|
source.ShouldContain("writeLock");
|
||||||
|
source.ShouldContain("lock (writeLock)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="WriteCommand.ParseValue"/> across every FOCAS atomic type.
|
||||||
|
/// Driver.FOCAS.Cli-001: malformed numeric input must surface as a friendly
|
||||||
|
/// <see cref="CliFx.Exceptions.CommandException"/>, not a raw
|
||||||
|
/// <see cref="FormatException"/> / <see cref="OverflowException"/> stack trace.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class WriteCommandParseValueTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("true", true)]
|
||||||
|
[InlineData("0", false)]
|
||||||
|
[InlineData("yes", true)]
|
||||||
|
[InlineData("OFF", false)]
|
||||||
|
public void ParseValue_Bit_accepts_common_boolean_aliases(string raw, bool expected)
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue(raw, FocasDataType.Bit).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Bit_rejects_garbage_as_CommandException()
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("maybe", FocasDataType.Bit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Byte_signed_range()
|
||||||
|
{
|
||||||
|
// FocasDataType.Byte is signed (PMC byte read returns int8).
|
||||||
|
WriteCommand.ParseValue("-128", FocasDataType.Byte).ShouldBe((sbyte)-128);
|
||||||
|
WriteCommand.ParseValue("127", FocasDataType.Byte).ShouldBe((sbyte)127);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int16_signed_range()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-32768", FocasDataType.Int16).ShouldBe(short.MinValue);
|
||||||
|
WriteCommand.ParseValue("32767", FocasDataType.Int16).ShouldBe(short.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Int32_parses_negative()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("-2147483648", FocasDataType.Int32).ShouldBe(int.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float32_invariant_culture()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("3.14", FocasDataType.Float32).ShouldBe(3.14f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_Float64_higher_precision()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("2.718281828", FocasDataType.Float64).ShouldBeOfType<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_String_passthrough()
|
||||||
|
{
|
||||||
|
WriteCommand.ParseValue("hello fanuc", FocasDataType.String).ShouldBe("hello fanuc");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver.FOCAS.Cli-001: malformed input must produce a CommandException (a clean
|
||||||
|
// one-line CliFx error), NOT a raw FormatException stack trace. Previously the raw
|
||||||
|
// BCL parser exceptions leaked, contradicting how the Bit path already handled bad
|
||||||
|
// boolean input.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("xyz", FocasDataType.Byte)]
|
||||||
|
[InlineData("xyz", FocasDataType.Int16)]
|
||||||
|
[InlineData("xyz", FocasDataType.Int32)]
|
||||||
|
[InlineData("not-a-number", FocasDataType.Float32)]
|
||||||
|
[InlineData("also-bad", FocasDataType.Float64)]
|
||||||
|
public void ParseValue_non_numeric_for_numeric_types_throws_CommandException(
|
||||||
|
string raw, FocasDataType type)
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue(raw, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverflowException from out-of-range input must also surface as CommandException.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("128", FocasDataType.Byte)] // sbyte max + 1
|
||||||
|
[InlineData("-129", FocasDataType.Byte)] // sbyte min - 1
|
||||||
|
[InlineData("32768", FocasDataType.Int16)] // short max + 1
|
||||||
|
[InlineData("9999999999", FocasDataType.Int32)] // > int max
|
||||||
|
public void ParseValue_overflow_for_numeric_types_throws_CommandException(
|
||||||
|
string raw, FocasDataType type)
|
||||||
|
{
|
||||||
|
Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue(raw, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseValue_CommandException_message_names_the_type_and_value()
|
||||||
|
{
|
||||||
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(
|
||||||
|
() => WriteCommand.ParseValue("xyz", FocasDataType.Int16));
|
||||||
|
ex.Message.ShouldContain("xyz");
|
||||||
|
ex.Message.ShouldContain("Int16");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("R100", FocasDataType.Int16, "R100:Int16")]
|
||||||
|
[InlineData("X0.0", FocasDataType.Bit, "X0.0:Bit")]
|
||||||
|
[InlineData("PARAM:1815/0", FocasDataType.Int32, "PARAM:1815/0:Int32")]
|
||||||
|
[InlineData("MACRO:500", FocasDataType.Float64, "MACRO:500:Float64")]
|
||||||
|
public void SynthesiseTagName_preserves_FOCAS_address_verbatim(
|
||||||
|
string address, FocasDataType type, string expected)
|
||||||
|
{
|
||||||
|
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\src\Drivers\Cli\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user