fix(driver-twincat-cli): resolve Low code-review findings (Driver.TwinCAT.Cli-001,002,003,004,005,006,007)

- Driver.TwinCAT.Cli-001: TwinCATCommandBase.Validate rejects
  non-positive TimeoutMs / IntervalMs and AmsPort outside 1..65535;
  ExecuteAsync calls it first.
- Driver.TwinCAT.Cli-002: SubscribeCommand serialises every WriteLine
  through a writeLock to remove the notification-callback vs banner
  interleave risk.
- Driver.TwinCAT.Cli-003: SubscribeCommand.DescribeMechanism derives
  the banner label from the returned ISubscriptionHandle.DiagnosticId
  so it can't disagree with what the driver actually did.
- Driver.TwinCAT.Cli-004: introduced TwinCATTagCommandBase carrying
  --poll-only + BuildOptions; BrowseCommand stays on the slimmer
  TwinCATCommandBase so --poll-only no longer surfaces in browse --help.
- Driver.TwinCAT.Cli-005: ProbeCommand --type now carries the 't' short
  alias to match the other commands.
- Driver.TwinCAT.Cli-006: 35 new tests covering Gateway / AmsAddress
  parse / BuildOptions / PollOnly / browse-helpers / probe-alias /
  mechanism derivation.
- Driver.TwinCAT.Cli-007: replaced the empty-init <inheritdoc/> with an
  explicit summary warning future maintainers about the no-op init.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:34:57 -04:00
parent 67ef6c4ebc
commit f2ee027145
11 changed files with 642 additions and 60 deletions

View File

@@ -0,0 +1,123 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
/// <summary>
/// Driver.TwinCAT.Cli-006: covers the prefix / max filtering and the RO/RW classification
/// logic inside <see cref="BrowseCommand"/>. The collecting builder + the filter pipeline
/// are pure — no ADS contact required — so they unit-test cleanly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BrowseCommandFilterTests
{
private static DriverAttributeInfo Info(SecurityClassification cls, DriverDataType dt = DriverDataType.Int32)
=> new(
FullName: "ignored",
DriverDataType: dt,
IsArray: false,
ArrayDim: null,
SecurityClass: cls,
IsHistorized: false);
[Fact]
public void Collector_records_each_variable_in_call_order()
{
var builder = new BrowseCommand.CollectingAddressSpaceBuilder();
builder.Variable("GVL.A", "GVL.A", Info(SecurityClassification.Operate));
builder.Variable("GVL.B", "GVL.B", Info(SecurityClassification.ViewOnly));
builder.Variables.Count.ShouldBe(2);
builder.Variables[0].BrowseName.ShouldBe("GVL.A");
builder.Variables[1].BrowseName.ShouldBe("GVL.B");
}
[Fact]
public void Folder_returns_same_builder_so_nested_variables_land_in_one_flat_list()
{
// BrowseCommand expects a flat list — TwinCAT's flat-mode symbol walk doesn't nest
// into sub-folders, but DiscoverAsync may still call Folder() before Variable().
var builder = new BrowseCommand.CollectingAddressSpaceBuilder();
var nested = builder.Folder("Discovered", "Discovered");
nested.Variable("GVL.X", "GVL.X", Info(SecurityClassification.Operate));
builder.Variables.Count.ShouldBe(1);
builder.Variables[0].BrowseName.ShouldBe("GVL.X");
}
[Fact]
public void FilterAndLimit_empty_prefix_returns_everything_up_to_max()
{
var symbols = new List<(string BrowseName, DriverAttributeInfo Info)>
{
("GVL_A.x", Info(SecurityClassification.Operate)),
("GVL_B.y", Info(SecurityClassification.ViewOnly)),
("MAIN.z", Info(SecurityClassification.Operate)),
};
var matched = BrowseCommand.FilterByPrefix(symbols, prefix: null);
matched.Count.ShouldBe(3);
}
[Fact]
public void FilterAndLimit_prefix_is_case_sensitive()
{
var symbols = new List<(string BrowseName, DriverAttributeInfo Info)>
{
("GVL_Fixture.x", Info(SecurityClassification.Operate)),
("gvl_fixture.y", Info(SecurityClassification.Operate)),
("MAIN.z", Info(SecurityClassification.Operate)),
};
var matched = BrowseCommand.FilterByPrefix(symbols, prefix: "GVL_Fixture");
matched.Count.ShouldBe(1);
matched[0].BrowseName.ShouldBe("GVL_Fixture.x");
}
[Fact]
public void FilterAndLimit_zero_max_means_unbounded()
{
var symbols = Enumerable.Range(0, 10)
.Select(i => ($"GVL.S{i}", Info(SecurityClassification.Operate)))
.ToList();
var limit = BrowseCommand.PrintLimit(symbols.Count, max: 0);
limit.ShouldBe(10);
}
[Fact]
public void FilterAndLimit_caps_to_max_when_more_matched()
{
BrowseCommand.PrintLimit(matchedCount: 1000, max: 50).ShouldBe(50);
}
[Fact]
public void FilterAndLimit_does_not_pad_to_max_when_fewer_matched()
{
BrowseCommand.PrintLimit(matchedCount: 3, max: 50).ShouldBe(3);
}
[Fact]
public void AccessTag_returns_RO_for_ViewOnly_attribute()
{
BrowseCommand.AccessTag(Info(SecurityClassification.ViewOnly)).ShouldBe("RO");
}
[Theory]
[InlineData(SecurityClassification.FreeAccess)]
[InlineData(SecurityClassification.Operate)]
[InlineData(SecurityClassification.SecuredWrite)]
[InlineData(SecurityClassification.VerifiedWrite)]
[InlineData(SecurityClassification.Tune)]
[InlineData(SecurityClassification.Configure)]
public void AccessTag_returns_RW_for_anything_except_ViewOnly(SecurityClassification cls)
{
// BrowseCommand's display logic flips ViewOnly = RO, everything else = RW. The real
// ACL is enforced server-side from the SecurityClassification — the CLI label is just
// a coarse "is this writable from any tier" indicator.
BrowseCommand.AccessTag(Info(cls)).ShouldBe("RW");
}
}

View File

@@ -0,0 +1,37 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
/// <summary>
/// Driver.TwinCAT.Cli-003: the subscribe banner mechanism label is derived from the
/// <see cref="ISubscriptionHandle.DiagnosticId"/> the driver actually returned, not from
/// the <c>--poll-only</c> flag. That way the banner cannot disagree with what the driver
/// did even if a future fallback path lands the subscription somewhere unexpected.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SubscribeCommandMechanismTests
{
private sealed record StubHandle(string DiagnosticId) : ISubscriptionHandle;
[Theory]
[InlineData("twincat-native-sub-1")]
[InlineData("twincat-native-sub-42")]
[InlineData("twincat-native-sub-9223372036854775807")]
public void DescribeMechanism_returns_ADS_notification_for_native_handle(string diagId)
{
SubscribeCommand.DescribeMechanism(new StubHandle(diagId)).ShouldBe("ADS notification");
}
[Theory]
[InlineData("pollgroup-1")]
[InlineData("modbus-poll-7")]
[InlineData("")]
[InlineData("TWINCAT-NATIVE-SUB-1")] // ordinal comparison — uppercase prefix does NOT match.
public void DescribeMechanism_returns_polling_for_anything_else(string diagId)
{
SubscribeCommand.DescribeMechanism(new StubHandle(diagId)).ShouldBe("polling");
}
}

View File

@@ -0,0 +1,238 @@
using System.Reflection;
using CliFx.Attributes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
/// <summary>
/// Covers <see cref="TwinCATCommandBase"/> / <see cref="TwinCATTagCommandBase"/> wiring:
/// the canonical gateway string, the driver instance id, the BuildOptions field projection
/// (Driver.TwinCAT.Cli-006), and the up-front range validation guards
/// (Driver.TwinCAT.Cli-001).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATCommandBaseTests
{
[Fact]
public void Gateway_uses_canonical_ads_scheme_with_port()
{
var cmd = new ProbeCommand
{
AmsNetId = "192.168.1.40.1.1",
AmsPort = 851,
SymbolPath = "MAIN.bRunning",
};
cmd.GatewayForTest.ShouldBe("ads://192.168.1.40.1.1:851");
}
[Fact]
public void Gateway_round_trips_through_TwinCATAmsAddress_TryParse()
{
// Driver.TwinCAT.Cli-006: a regression in the Gateway string breaks every command
// because the driver's TwinCATAmsAddress.TryParse refuses anything not shaped
// ads://{netId}:{port}.
var cmd = new ProbeCommand
{
AmsNetId = "5.23.91.23.1.1",
AmsPort = 852,
SymbolPath = "MAIN.x",
};
var parsed = TwinCAT.TwinCATAmsAddress.TryParse(cmd.GatewayForTest);
parsed.ShouldNotBeNull();
parsed!.NetId.ShouldBe("5.23.91.23.1.1");
parsed.Port.ShouldBe(852);
}
[Fact]
public void DriverInstanceId_includes_ams_target()
{
var cmd = new ProbeCommand
{
AmsNetId = "127.0.0.1.1.1",
AmsPort = 851,
SymbolPath = "MAIN.x",
};
cmd.DriverInstanceIdForTest.ShouldBe("twincat-cli-127.0.0.1.1.1:851");
}
[Fact]
public void Timeout_is_projection_of_TimeoutMs_and_init_is_noop()
{
var cmd = new ProbeCommand
{
AmsNetId = "127.0.0.1.1.1",
TimeoutMs = 7777,
SymbolPath = "MAIN.x",
};
cmd.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7777));
}
[Fact]
public void BuildOptions_wires_device_tags_timeout_and_disables_probe()
{
// Driver.TwinCAT.Cli-006: cover the property-by-property wiring that the four runtime
// commands depend on. Probe must be disabled (CLI is one-shot — the probe loop would
// race the operator's own reads) and controller-browse must stay off.
var cmd = new ProbeCommand
{
AmsNetId = "10.0.0.1.1.1",
AmsPort = 851,
TimeoutMs = 4321,
SymbolPath = "MAIN.x",
};
var tag = new TwinCAT.TwinCATTagDefinition(
Name: "n1",
DeviceHostAddress: cmd.GatewayForTest,
SymbolPath: "MAIN.x",
DataType: TwinCAT.TwinCATDataType.DInt,
Writable: false);
var options = cmd.BuildOptionsForTest([tag]);
options.Devices.Count.ShouldBe(1);
options.Devices[0].HostAddress.ShouldBe("ads://10.0.0.1.1.1:851");
options.Devices[0].DeviceName.ShouldBe("cli-10.0.0.1.1.1:851");
options.Tags.ShouldBe([tag]);
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(4321));
options.Probe.Enabled.ShouldBeFalse();
options.EnableControllerBrowse.ShouldBeFalse();
// Default UseNativeNotifications = true (no --poll-only).
options.UseNativeNotifications.ShouldBeTrue();
}
[Fact]
public void BuildOptions_PollOnly_flips_UseNativeNotifications_off()
{
var cmd = new ProbeCommand
{
AmsNetId = "10.0.0.1.1.1",
SymbolPath = "MAIN.x",
PollOnly = true,
};
cmd.BuildOptionsForTest([]).UseNativeNotifications.ShouldBeFalse();
}
// ---- Driver.TwinCAT.Cli-001 (range validation) ----
[Fact]
public void Validate_rejects_zero_timeout()
{
var cmd = new ProbeCommand
{
AmsNetId = "127.0.0.1.1.1",
SymbolPath = "MAIN.x",
TimeoutMs = 0,
};
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => cmd.ValidateForTest());
ex.Message.ShouldContain("--timeout-ms");
}
[Fact]
public void Validate_rejects_negative_timeout()
{
var cmd = new ProbeCommand
{
AmsNetId = "127.0.0.1.1.1",
SymbolPath = "MAIN.x",
TimeoutMs = -1,
};
Should.Throw<CliFx.Exceptions.CommandException>(() => cmd.ValidateForTest());
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(65536)]
[InlineData(100000)]
public void Validate_rejects_out_of_range_ams_port(int port)
{
var cmd = new ProbeCommand
{
AmsNetId = "127.0.0.1.1.1",
SymbolPath = "MAIN.x",
AmsPort = port,
};
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => cmd.ValidateForTest());
ex.Message.ShouldContain("--ams-port");
}
[Theory]
[InlineData(1)]
[InlineData(801)]
[InlineData(851)]
[InlineData(65535)]
public void Validate_accepts_in_range_ams_port(int port)
{
var cmd = new ProbeCommand
{
AmsNetId = "127.0.0.1.1.1",
SymbolPath = "MAIN.x",
AmsPort = port,
};
Should.NotThrow(() => cmd.ValidateForTest());
}
[Fact]
public void SubscribeCommand_validate_rejects_zero_interval()
{
var cmd = new SubscribeCommand
{
AmsNetId = "127.0.0.1.1.1",
SymbolPath = "MAIN.x",
IntervalMs = 0,
};
var ex = Should.Throw<CliFx.Exceptions.CommandException>(() => cmd.ValidateForTest());
ex.Message.ShouldContain("--interval-ms");
}
[Fact]
public void SubscribeCommand_validate_rejects_negative_interval()
{
var cmd = new SubscribeCommand
{
AmsNetId = "127.0.0.1.1.1",
SymbolPath = "MAIN.x",
IntervalMs = -100,
};
Should.Throw<CliFx.Exceptions.CommandException>(() => cmd.ValidateForTest());
}
// ---- Driver.TwinCAT.Cli-004 (PollOnly off BrowseCommand surface) ----
[Fact]
public void BrowseCommand_does_not_expose_poll_only_flag()
{
// Driver.TwinCAT.Cli-004: the flag has no observable effect on browse — surfacing it
// misleads users. After the refactor, PollOnly lives on an intermediate base shared
// only by the commands that actually consume native ADS notifications.
var props = typeof(BrowseCommand)
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
props.ShouldNotContain(p => p.Name == "PollOnly");
}
[Fact]
public void ProbeCommand_still_exposes_poll_only_flag()
{
// Probe / Read / Write / Subscribe all build TwinCATDriverOptions and so still take
// the --poll-only toggle.
var props = typeof(ProbeCommand)
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
props.ShouldContain(p => p.Name == "PollOnly");
}
// ---- Driver.TwinCAT.Cli-005 (probe --type short alias) ----
[Fact]
public void ProbeCommand_type_option_carries_short_alias_t()
{
// Driver.TwinCAT.Cli-005: --type on read/write/subscribe takes the -t short alias;
// probe must match so muscle memory works the same way across all four verbs.
var dataTypeProp = typeof(ProbeCommand).GetProperty("DataType");
dataTypeProp.ShouldNotBeNull();
var attr = dataTypeProp!.GetCustomAttribute<CommandOptionAttribute>();
attr.ShouldNotBeNull();
attr!.ShortName.ShouldBe('t');
}
}