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:
@@ -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