c0379742bc
Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.
TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
256 lines
8.5 KiB
C#
256 lines
8.5 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Invokes the protected <see cref="AbCipCommandBase.BuildOptions"/> method.
|
|
/// </summary>
|
|
/// <param name="tags">The tags to pass to BuildOptions.</param>
|
|
/// <returns>The configured driver options.</returns>
|
|
public AbCipDriverOptions InvokeBuildOptions(IReadOnlyList<AbCipTagDefinition> tags)
|
|
=> BuildOptions(tags);
|
|
|
|
/// <summary>
|
|
/// Gets the protected <see cref="AbCipCommandBase.DriverInstanceId"/> property.
|
|
/// </summary>
|
|
public string InvokeDriverInstanceId => DriverInstanceId;
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildOptions disables probe to prevent racing operator reads.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildOptions disables controller browse.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildOptions disables alarm projection.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildOptions produces one device with gateway family and derived name.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildOptions passes the supplied tag list verbatim.
|
|
/// </summary>
|
|
[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);
|
|
|
|
// v3: BuildOptions projects each typed tag to a RawTagEntry (RawPath = tag name).
|
|
options.RawTags.Count.ShouldBe(2);
|
|
options.RawTags[0].RawPath.ShouldBe("t1");
|
|
options.RawTags[1].RawPath.ShouldBe("t2");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BuildOptions carries TimeoutMs through to Timeout.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that DriverInstanceId embeds gateway for log disambiguation.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that Timeout setter is inert and does not silently swallow assignments.
|
|
/// </summary>
|
|
[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),
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that Timeout getter throws CommandException when TimeoutMs is non-positive.
|
|
/// </summary>
|
|
/// <param name="badMs">A non-positive timeout value to test.</param>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that RejectStructure throws for Structure DataType.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RejectStructure_throws_for_Structure_DataType()
|
|
{
|
|
var ex = Should.Throw<CliFx.Exceptions.CommandException>(
|
|
() => CallRejectStructure(AbCipDataType.Structure));
|
|
ex.Message.ShouldContain("Structure");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that RejectStructure passes for atomic data types.
|
|
/// </summary>
|
|
/// <param name="type">The atomic data type to test.</param>
|
|
[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;
|
|
}
|
|
}
|
|
}
|