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:
Joseph Doherty
2026-05-23 08:34:25 -04:00
parent 61c0311938
commit 759af8c1bb
9 changed files with 348 additions and 20 deletions

View File

@@ -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;
}
}
}