Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
T
Joseph Doherty 6b3da93151
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 16m47s
fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.

The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.

Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.

--- and the other half, which the fixture was hiding ---

Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:

  * @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
    @udt/ are), so it goes out as an ordinary symbolic name that no controller
    defines, and the controller's "tag does not exist" IS the successful answer.
  * ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
    controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
    classifies oppositely. Fixed in vendored patch 0002.
  * With the fixture then telling the truth, the real defect surfaced:
    AbCipDriverProbe.IsReachableException matched the native C status spellings
    ("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
    ("ErrorNotFound"). Those substrings can never match, so the reachable
    classification could not fire on the exception path — which is the path every
    no-authored-tags probe takes. This was failing on real hardware too, not just
    against the sim. It now recovers the Status and defers to IsReachableStatus,
    so there is one classification rather than two that can drift.

Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.

Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".

Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 15:20:07 -04:00

748 lines
37 KiB
C#

using System.Buffers.Binary;
using System.Runtime.CompilerServices;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverDiscoveryTests
{
/// <summary>Verifies that pre-declared tags emit as variables under device folder.</summary>
[Fact]
public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
builder.Variables.Count.ShouldBe(2);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>
/// Every emitted variable states its Logix type, so a browse-commit binds the tag with the type
/// the controller has rather than the tag model's <c>DInt</c> default (Gitea #536). The
/// driver-agnostic <see cref="DriverAttributeInfo.DriverDataType"/> cannot serve — <c>SInt</c>,
/// <c>Int</c> and <c>DInt</c> all arrive there as <see cref="DriverDataType.Int32"/>.
/// </summary>
[Fact]
public async Task Emitted_variables_state_their_Logix_type_for_the_commit_path()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.Real),
new AbCipTagDefinition("Count", "ab://10.0.0.5/1,0", "C", AbCipDataType.Int)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.NativeDataType.ShouldBe("Real");
// Int and DInt are indistinguishable downstream — this pair is the whole reason the native
// name is carried, so assert both halves rather than only the one that differs by kind.
var count = builder.Variables.Single(v => v.BrowseName == "Count").Info;
count.NativeDataType.ShouldBe("Int");
count.DriverDataType.ShouldBe(DriverDataType.Int32);
}
/// <summary>Verifies that device folder display name falls back to host address when not provided.</summary>
[Fact]
public async Task Device_folder_displayname_falls_back_to_host_address()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], // no DeviceName
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0"
&& f.DisplayName == "ab://10.0.0.5/1,0");
}
/// <summary>Verifies that pre-declared system tags are filtered out.</summary>
[Fact]
public async Task PreDeclared_system_tags_are_filtered_out()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
}
/// <summary>Verifies that tags for mismatched devices are ignored.</summary>
[Fact]
public async Task Tags_for_mismatched_device_are_ignored()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldBeEmpty();
}
/// <summary>Verifies that controller enumeration adds tags under Discovered folder.</summary>
[Fact]
public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
{
var builder = new RecordingBuilder();
var enumeratorFactory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false),
new AbCipDiscoveredTag("StepIndex", ProgramScope: "MainProgram", AbCipDataType.DInt, 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);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Pressure");
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>
/// PRODUCTION PATH (no test seam): a controller-discovered Structure tag carries its CIP
/// template instance id (surfaced by the Symbol Object decoder onto
/// <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>). DiscoverAsync threads that id into
/// <c>FetchUdtShapeAsync</c>, which reads the Template Object off the controller via the
/// injected <see cref="IAbCipTemplateReader"/> and fans the top-level UDT out into atomic
/// member Variables — WITHOUT any call to <c>SeedDiscoveredUdtShapeForTest</c>. This proves the
/// production top-level expansion is functional, not inert.
/// </summary>
[Fact]
public async Task Controller_discovered_UDT_top_level_expands_via_template_id_fetch_no_seam()
{
const string device = "ab://10.0.0.5/1,0";
const uint motorTemplateId = 0x2A; // lower 12 bits of the struct symbol type
var builder = new RecordingBuilder();
// Discovered tag carries the template instance id — exactly what the real Symbol Object
// decoder produces for a struct symbol.
var enumeratorFactory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: motorTemplateId));
// Fake template reader keyed by id: a Read Template for motorTemplateId returns a real
// Template Object blob with two atomic members (Speed/Real, Running/Bool). No seed seam.
var templateReaderFactory = new IdKeyedTemplateReaderFactory
{
Templates =
{
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 8,
("Speed", 0xCA, 0, 0), // 0xCA = Real
("Running", 0xC1, 0, 4)), // 0xC1 = Bool
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device)],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
// The reader was actually consulted for the discovered tag's template id — the production
// fetch path ran (not the seam).
templateReaderFactory.LastTemplateId.ShouldBe(motorTemplateId);
// The Motor1 sub-folder + atomic member leaves are emitted; the bogus Structure placeholder
// Variable is NOT.
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
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);
}
/// <summary>
/// PRODUCTION PATH (no test seam): a controller-discovered UDT whose Template Object carries
/// a NESTED struct member fans the nested member out into its own atomic leaves WITHOUT any
/// <c>SeedDiscoveredUdtShapeForTest</c>. The nested member block carries the nested UDT's
/// template instance id (low 12 bits, struct flag set); the decoder captures it as
/// <c>NestedTemplateId</c> and DiscoverAsync threads it into a real <c>FetchUdtShapeAsync</c>
/// for the nested id. Proves the nested fetch actually happened (reader consulted for the
/// nested id) and that the nested struct's leaf member is addressable.
/// </summary>
[Fact]
public async Task Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam()
{
const string device = "ab://10.0.0.5/1,0";
const uint motorTemplateId = 0x2A; // parent UDT template id
const uint statusTemplateId = 0x123; // nested UDT template id — exceeds a byte (12-bit proof)
var builder = new RecordingBuilder();
var enumeratorFactory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false,
TemplateInstanceId: motorTemplateId));
// The parent template carries an atomic member (Speed/Real) + a NESTED struct member (Status)
// whose member-info encodes the struct flag (0x8000) | the nested template id (0x123). The
// nested template id is read off @udt/0x123 and yields one atomic leaf (Code/DInt).
var templateReaderFactory = new IdKeyedTemplateReaderFactory
{
Templates =
{
[motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12,
("Speed", 0xCA, 0, 0), // Real
("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)), // struct + nested id 0x123
[statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4,
("Code", 0xC4, 0, 0)), // DInt
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device)],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
// The nested template id actually drove a fetch — the reader was consulted for it (the seam
// would have left LastTemplateId at the parent id and never read the nested shape).
templateReaderFactory.RequestedIds.ShouldContain(statusTemplateId);
var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
// The parent atomic leaf is emitted.
byName.ShouldContainKey("Motor1.Speed");
byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
// The NESTED struct's atomic leaf is addressable — proving the nested fetch + fan-out ran.
byName.ShouldContainKey("Motor1.Status.Code");
byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
// A Motor1.Status sub-folder was materialised for the nested struct.
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1.Status");
}
/// <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>
/// A nested struct member whose sub-shape resolves but yields NO emittable atomic leaf must
/// not leave an empty nested sub-folder in the browse tree — the folder is materialised lazily,
/// only once a leaf is actually emitted into it. Here <c>Empty</c> (a nested struct) has only
/// a further nested struct child whose own shape is unresolvable, so the whole <c>Empty</c>
/// branch produces no leaf and must produce no folder either; the sibling atomic leaf is kept.
/// </summary>
[Fact]
public async Task Controller_discovered_UDT_does_not_leave_empty_nested_subfolder()
{
const string device = "ab://10.0.0.5/1,0";
var builder = new RecordingBuilder();
var enumeratorFactory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("Top", 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);
// Top has one atomic leaf (Ok) + a nested struct (Empty). Empty's shape resolves, but its
// only member is a further nested struct (Ghost) whose shape is NOT seeded → unresolvable →
// no leaf anywhere under Empty.
drv.SeedDiscoveredUdtShapeForTest(device, "Top", new AbCipUdtShape("TopUdt", 8,
[
new AbCipUdtMember("Ok", 0, AbCipDataType.DInt, ArrayLength: 1),
new AbCipUdtMember("Empty", 4, AbCipDataType.Structure, ArrayLength: 1),
]));
drv.SeedDiscoveredUdtShapeForTest(device, "Empty", new AbCipUdtShape("EmptyUdt", 4,
[
new AbCipUdtMember("Ghost", 0, AbCipDataType.Structure, ArrayLength: 1),
]));
await drv.DiscoverAsync(builder, CancellationToken.None);
// The Top folder + its atomic leaf are emitted.
builder.Folders.ShouldContain(f => f.BrowseName == "Top");
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Top.Ok");
// The Empty nested struct yields no leaf → no empty sub-folder is created for it.
builder.Folders.ShouldNotContain(f => f.BrowseName == "Empty");
builder.Variables.Select(v => v.Info.FullName).ShouldNotContain(n => n.StartsWith("Top.Empty", StringComparison.Ordinal));
}
/// <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 member-path read goes end-to-end through the driver: an authored raw tag whose TagConfig
/// addresses a dotted member path (<c>Motor1.Status.Code</c>) is delivered as a
/// <see cref="RawTagEntry"/>, resolves by its RawPath, materialises a runtime on the device, and
/// decodes the member's atomic value. The libplctag tag name the driver builds equals the dotted
/// member path (the TagConfig's tagPath), independent of the RawPath identity.
/// </summary>
[Fact]
public async Task Member_path_tag_reads_through_driver_as_member_atomic_type()
{
const string device = "ab://10.0.0.5/1,0";
const string rawPath = "line1/motor1/status/code";
// The authored TagConfig addresses the dotted member path (atomic Real here).
const string tagConfig = "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\"}";
var tagFactory = new FakeAbCipTagFactory
{
// The driver creates the runtime keyed by the libplctag tag name = the dotted member
// path; seed a Real value the read should surface.
Customise = p => new FakeAbCipTag(p) { Value = p.TagName == "Motor1.Status.Code" ? 12.5f : null },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device)],
RawTags = [new RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)],
}, "drv-1", tagFactory: tagFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([rawPath], CancellationToken.None);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[0].Value.ShouldBe(12.5f);
// The driver built a runtime under the dotted member tag name — confirming the member-path
// read addresses the member directly.
tagFactory.Tags.ShouldContainKey("Motor1.Status.Code");
}
/// <summary>Verifies that controller enumeration honours system tag hint and filter.</summary>
[Fact]
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
{
var builder = new RecordingBuilder();
var factory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("HiddenByHint", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: true),
new AbCipDiscoveredTag("Routine:Foo", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: false),
new AbCipDiscoveredTag("KeepMe", null, AbCipDataType.DInt, ReadOnly: false));
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
}
/// <summary>Verifies that controller enumeration ReadOnly flag surfaces ViewOnly classification.</summary>
[Fact]
public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
{
var builder = new RecordingBuilder();
var factory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("SafetyTag", null, AbCipDataType.DInt, ReadOnly: true));
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that controller enumeration receives correct device parameters.</summary>
[Fact]
public async Task Controller_enumeration_receives_correct_device_params()
{
var factory = new FakeEnumeratorFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
Timeout = TimeSpan.FromSeconds(7),
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
var capturedParams = factory.LastDeviceParams.ShouldNotBeNull();
capturedParams.Gateway.ShouldBe("10.0.0.5");
capturedParams.Port.ShouldBe(44818);
capturedParams.CipPath.ShouldBe("1,2,3");
capturedParams.LibplctagPlcAttribute.ShouldBe("controllogix");
capturedParams.TagName.ShouldBe("@tags");
capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
}
/// <summary>Verifies that default enumerator factory is used when not injected.</summary>
[Fact]
public void Default_enumerator_factory_is_used_when_not_injected()
{
// Sanity — absent enumerator factory does not crash discovery + uses EmptyAbCipTagEnumerator
// (covered by the other tests which instantiate without injecting a factory).
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1");
drv.ShouldNotBeNull();
}
/// <summary>Verifies that system tag filter rejects infrastructure names.</summary>
/// <param name="name">The tag name to test.</param>
/// <param name="expected">The expected result of the filter.</param>
[Theory]
[InlineData("__DEFVAL_X", true)]
[InlineData("__DEFAULT_Y", true)]
[InlineData("Routine:Main", true)]
[InlineData("Task:MainTask", true)]
[InlineData("Local:1:I", true)]
[InlineData("Map:Alias", true)]
[InlineData("Axis:MoveX", true)]
[InlineData("Cam:Profile1", true)]
[InlineData("MotionGroup:MG0", true)]
[InlineData("Motor1", false)]
[InlineData("Program:Main.Step", false)]
[InlineData("Recipe_2", false)]
[InlineData("", true)]
[InlineData(" ", true)]
public void SystemTagFilter_rejects_infrastructure_names(string name, bool expected)
{
AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
}
/// <summary>Verifies that template cache roundtrip put and get work correctly.</summary>
[Fact]
public void TemplateCache_roundtrip_put_get()
{
var cache = new AbCipTemplateCache();
var shape = new AbCipUdtShape("MyUdt", 32,
[
new AbCipUdtMember("A", 0, AbCipDataType.DInt, ArrayLength: 1),
new AbCipUdtMember("B", 4, AbCipDataType.Real, ArrayLength: 1),
]);
cache.Put("ab://10.0.0.5/1,0", 42, shape);
cache.TryGet("ab://10.0.0.5/1,0", 42).ShouldBe(shape);
cache.TryGet("ab://10.0.0.5/1,0", 99).ShouldBeNull();
cache.Count.ShouldBe(1);
cache.Clear();
cache.Count.ShouldBe(0);
}
// ---- helpers ----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of recorded folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of recorded variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records a folder node.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable node.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The attribute information for the variable.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Adds a property (no-op in test).</summary>
/// <param name="_">Property name (unused in test).</param>
/// <param name="__">Property data type (unused in test).</param>
/// <param name="___">Property value (unused in test).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
/// <summary>Test variable handle.</summary>
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference of the variable.</summary>
public string FullReference => fullRef;
/// <summary>Marks the variable as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
/// <summary>Null sink for alarm conditions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Handles alarm transition (no-op).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
/// <summary>Fake enumerator factory for testing.</summary>
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _tags;
/// <summary>Gets the last captured device parameters.</summary>
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
/// <summary>Initializes a new instance of the FakeEnumeratorFactory.</summary>
/// <param name="tags">The tags to enumerate.</param>
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
/// <summary>Creates a new fake enumerator.</summary>
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
/// <summary>Fake tag enumerator for testing.</summary>
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
{
/// <summary>Enumerates discovered tags asynchronously.</summary>
/// <param name="deviceParams">The device parameters for enumeration.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
outer.LastDeviceParams = deviceParams;
await Task.CompletedTask;
foreach (var t in outer._tags) yield return t;
}
/// <summary>Disposes the enumerator.</summary>
public void Dispose() { }
}
}
/// <summary>
/// Fake <see cref="IAbCipTemplateReaderFactory"/> whose readers return a Template Object blob
/// keyed by template instance id — the production fetch path (FetchUdtShapeAsync) keyed by the
/// id the Symbol Object decoder surfaces, with NO use of the seed seam.
/// </summary>
private sealed class IdKeyedTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <summary>Gets the template blobs keyed by template instance id.</summary>
public Dictionary<uint, byte[]> Templates { get; } = new();
/// <summary>Gets the last template id any reader was asked for.</summary>
public uint? LastTemplateId { get; private set; }
/// <summary>Gets every template id any reader was asked for, in request order.</summary>
public List<uint> RequestedIds { get; } = new();
/// <summary>Creates a reader over the shared template map.</summary>
public IAbCipTemplateReader Create() => new Reader(this);
private sealed class Reader(IdKeyedTemplateReaderFactory outer) : IAbCipTemplateReader
{
public Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
{
outer.LastTemplateId = templateInstanceId;
outer.RequestedIds.Add(templateInstanceId);
return Task.FromResult(
outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []);
}
public void Dispose() { }
}
}
/// <summary>
/// Build a minimal CIP Template Object blob <see cref="CipTemplateObjectDecoder"/> can decode
/// into an <see cref="AbCipUdtShape"/>. Mirrors the helper in
/// <c>AbCipFetchUdtShapeTests</c>: 12-byte header + 8-byte member blocks + semicolon/NUL
/// terminated UDT-then-member name strings.
/// </summary>
private static byte[] BuildSimpleTemplate(
string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
const int headerSize = 12;
const int blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
}