feat(abcip): expand controller-discovered UDTs into addressable member variables
This commit is contained in:
@@ -113,6 +113,168 @@ public sealed class AbCipDriverDiscoveryTests
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A controller-discovered UDT (a Structure tag) is expanded into a member sub-folder
|
||||
/// with one Variable per atomic member, mirroring the pre-declared fan-out. A nested
|
||||
/// struct member recurses into its own atomic leaves (dot-joined full names). The bogus
|
||||
/// single <c>Structure → String</c> placeholder Variable must NOT be emitted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Controller_discovered_UDT_expands_into_member_variables()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var enumeratorFactory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Seed the discovered-UDT shapes the driver fans out from. The top-level Motor1 shape
|
||||
// carries two atomic members (Speed/Real, Running/Bool) + a nested struct member
|
||||
// (Status) whose own shape (Code/DInt) is seeded under its member name.
|
||||
drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Motor1", new AbCipUdtShape(
|
||||
"MotorUdt", 16,
|
||||
[
|
||||
new AbCipUdtMember("Speed", 0, AbCipDataType.Real, ArrayLength: 1),
|
||||
new AbCipUdtMember("Running", 4, AbCipDataType.Bool, ArrayLength: 1),
|
||||
new AbCipUdtMember("Status", 8, AbCipDataType.Structure, ArrayLength: 1),
|
||||
]));
|
||||
drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Status", new AbCipUdtShape(
|
||||
"StatusUdt", 4,
|
||||
[
|
||||
new AbCipUdtMember("Code", 0, AbCipDataType.DInt, ArrayLength: 1),
|
||||
]));
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
// A Motor1 sub-folder is created under the Discovered folder.
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
|
||||
// The bare Structure placeholder Variable is NOT emitted (no Motor1 String node).
|
||||
builder.Variables.ShouldNotContain(v => v.Info.FullName == "Motor1");
|
||||
|
||||
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
|
||||
byName.ShouldContainKey("Motor1.Speed");
|
||||
byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
byName.ShouldContainKey("Motor1.Running");
|
||||
byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
// Nested struct leaf — dot-joined full name + the nested member's atomic type.
|
||||
byName.ShouldContainKey("Motor1.Status.Code");
|
||||
byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discovered Structure whose shape cannot be resolved degrades to the prior single
|
||||
/// Variable behavior (no regression): one Variable under the Discovered folder keyed by
|
||||
/// the bare struct name, never a broken member fan-out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Controller_discovered_UDT_with_unresolvable_shape_degrades_to_single_variable()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var enumeratorFactory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("MysteryUdt", null, AbCipDataType.Structure, ReadOnly: false));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// No shape seeded + no fake template reader → FetchUdtShapeAsync returns null → degrade.
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
var mystery = builder.Variables.Where(v => v.Info.FullName == "MysteryUdt").ToList();
|
||||
mystery.Count.ShouldBe(1);
|
||||
// No member fan-out folder for an unresolvable shape.
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "MysteryUdt");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nested-struct recursion is bounded by <c>MaxUdtDepth</c>: a member at the cap depth
|
||||
/// is dropped rather than emitted. Here the chain L0.L1.L2.… is seeded deeper than the
|
||||
/// cap; the leaf beyond the cap must not appear.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Controller_discovered_UDT_drops_members_deeper_than_MaxUdtDepth()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
const string device = "ab://10.0.0.5/1,0";
|
||||
var enumeratorFactory = new FakeEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Deep", null, AbCipDataType.Structure, ReadOnly: false));
|
||||
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(device)],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: enumeratorFactory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Build a chain Deep -> N1 -> N2 -> ... -> N12 (struct each level) with an atomic Leaf at
|
||||
// every level. AbCipDriver.MaxUdtDepth bounds how deep the fan-out recurses.
|
||||
const int chainDepth = 12;
|
||||
drv.SeedDiscoveredUdtShapeForTest(device, "Deep", new AbCipUdtShape("Deep", 8,
|
||||
[
|
||||
new AbCipUdtMember("Leaf", 0, AbCipDataType.DInt, ArrayLength: 1),
|
||||
new AbCipUdtMember("N1", 4, AbCipDataType.Structure, ArrayLength: 1),
|
||||
]));
|
||||
for (var i = 1; i <= chainDepth; i++)
|
||||
{
|
||||
var next = i < chainDepth ? new[] { new AbCipUdtMember($"N{i + 1}", 4, AbCipDataType.Structure, 1) } : [];
|
||||
var members = new List<AbCipUdtMember> { new($"Leaf", 0, AbCipDataType.DInt, 1) };
|
||||
members.AddRange(next);
|
||||
drv.SeedDiscoveredUdtShapeForTest(device, $"N{i}", new AbCipUdtShape($"N{i}", 8, members));
|
||||
}
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
var names = builder.Variables.Select(v => v.Info.FullName).ToHashSet();
|
||||
// The top-level leaf is present.
|
||||
names.ShouldContain("Deep.Leaf");
|
||||
// A shallow nested leaf (well within the cap) is kept.
|
||||
names.ShouldContain("Deep.N1.Leaf");
|
||||
names.ShouldContain("Deep.N1.N2.Leaf");
|
||||
// A leaf at the deepest chain level (well beyond the depth cap of 8) must be dropped.
|
||||
var deepestPrefix = string.Join('.', Enumerable.Range(1, chainDepth).Select(i => $"N{i}"));
|
||||
names.ShouldNotContain($"Deep.{deepestPrefix}.Leaf");
|
||||
// No member full-name should carry more than MaxUdtDepth path segments below the parent
|
||||
// (parent + at most MaxUdtDepth dotted segments). The deepest emitted leaf proves the cap.
|
||||
var maxSegmentsBelowParent = names
|
||||
.Where(n => n.StartsWith("Deep.", StringComparison.Ordinal))
|
||||
.Select(n => n.Split('.').Length - 1) // segments after "Deep"
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
maxSegmentsBelowParent.ShouldBeLessThanOrEqualTo(AbCipDriver.MaxUdtDepth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discovered member-path read resolves to the member's atomic type via the
|
||||
/// equipment-tag resolver over the authored TagConfig (FullName = <c>Parent.Member</c>),
|
||||
/// so no extra registration is needed for discovered members to be readable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Discovered_member_path_resolves_to_member_atomic_type()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
|
||||
// The wire reference handed to ReadAsync for a discovered member is the authored
|
||||
// TagConfig JSON blob (FullName = Motor1.Status.Code, atomic type Real here).
|
||||
const string tagConfig =
|
||||
"{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\",\"deviceHostAddress\":\"ab://10.0.0.5/1,0\"}";
|
||||
|
||||
AbCipEquipmentTagParser.TryParse(tagConfig, out var def).ShouldBeTrue();
|
||||
def.TagPath.ShouldBe("Motor1.Status.Code");
|
||||
def.DataType.ShouldBe(AbCipDataType.Real);
|
||||
def.DataType.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that controller enumeration honours system tag hint and filter.</summary>
|
||||
[Fact]
|
||||
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
|
||||
|
||||
Reference in New Issue
Block a user