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 { /// Verifies that pre-declared tags emit as variables under device folder. [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); } /// /// 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 DInt default (Gitea #536). The /// driver-agnostic cannot serve — SInt, /// Int and DInt all arrive there as . /// [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); } /// Verifies that device folder display name falls back to host address when not provided. [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"); } /// Verifies that pre-declared system tags are filtered out. [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"]); } /// Verifies that tags for mismatched devices are ignored. [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(); } /// Verifies that controller enumeration adds tags under Discovered folder. [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"); } /// /// 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 Structure → String placeholder Variable must NOT be emitted. /// [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); } /// /// PRODUCTION PATH (no test seam): a controller-discovered Structure tag carries its CIP /// template instance id (surfaced by the Symbol Object decoder onto /// ). DiscoverAsync threads that id into /// FetchUdtShapeAsync, which reads the Template Object off the controller via the /// injected and fans the top-level UDT out into atomic /// member Variables — WITHOUT any call to SeedDiscoveredUdtShapeForTest. This proves the /// production top-level expansion is functional, not inert. /// [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); } /// /// Gitea #572: a browse that fetches many templates (a UDT and its nested UDT here) reads them all /// through ONE batch reader, so the native transport holds one device lease across them rather than /// leasing per template. The batch reader is disposed when the device's browse ends. /// [Fact] public async Task Discovery_reads_every_template_through_one_batch_reader() { const string device = "ab://10.0.0.5/1,0"; const uint motorTemplateId = 0x2A; const uint statusTemplateId = 0x123; var enumeratorFactory = new FakeEnumeratorFactory( new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: motorTemplateId), new AbCipDiscoveredTag("Motor2", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: 0x2B)); var templates = new IdKeyedTemplateReaderFactory { Templates = { [motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12, ("Speed", 0xCA, 0, 0), ("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)), [0x2B] = BuildSimpleTemplate("Motor2Udt", 4, ("Speed", 0xCA, 0, 0)), [statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4, ("Code", 0xC4, 0, 0)), }, }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(device)], EnableControllerBrowse = true, }, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templates); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); templates.RequestedIds.Count.ShouldBe(3); templates.BatchReaders.ShouldBe(1); templates.PerCallReaders.ShouldBe(0); templates.BatchReadersDisposed.ShouldBe(1); // Outside a discovery run a fetch is still a self-contained per-call read. await drv.FetchUdtShapeAsync(device, 0x2B, CancellationToken.None); await drv.FetchUdtShapeAsync(device, 0x999, CancellationToken.None); templates.PerCallReaders.ShouldBe(1, "0x2B is cached; 0x999 is not"); } /// /// 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 /// SeedDiscoveredUdtShapeForTest. The nested member block carries the nested UDT's /// template instance id (low 12 bits, struct flag set); the decoder captures it as /// NestedTemplateId and DiscoverAsync threads it into a real FetchUdtShapeAsync /// 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. /// [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"); } /// /// 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. /// [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"); } /// /// 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 Empty (a nested struct) has only /// a further nested struct child whose own shape is unresolvable, so the whole Empty /// branch produces no leaf and must produce no folder either; the sibling atomic leaf is kept. /// [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)); } /// /// Nested-struct recursion is bounded by MaxUdtDepth: 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. /// [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 { 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); } /// /// A member-path read goes end-to-end through the driver: an authored raw tag whose TagConfig /// addresses a dotted member path (Motor1.Status.Code) is delivered as a /// , 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. /// [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"); } /// Verifies that controller enumeration honours system tag hint and filter. [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"]); } /// Verifies that controller enumeration ReadOnly flag surfaces ViewOnly classification. [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); } /// Verifies that controller enumeration receives correct device parameters. [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)); } /// Verifies that default enumerator factory is used when not injected. [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(); } /// Verifies that system tag filter rejects infrastructure names. /// The tag name to test. /// The expected result of the filter. [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); } /// Verifies that template cache roundtrip put and get work correctly. [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); } // ---- real-controller shapes (captures from a CompactLogix 5069-L320ERS2 rev 34.11) ---- private static async Task DiscoverWithTemplatesAsync( Dictionary templates, params AbCipDiscoveredTag[] tags) { var builder = new RecordingBuilder(); var readers = new IdKeyedTemplateReaderFactory(); foreach (var (id, blob) in templates) readers.Templates[id] = blob; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], EnableControllerBrowse = true, }, "drv-1", enumeratorFactory: new FakeEnumeratorFactory(tags), templateReaderFactory: readers); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); return builder; } /// /// A controller reports every STRING tag as a struct (symbol type 0x8FCE → template 0x0FCE, /// LEN + DATA). Browse must present it as the one String value it is — the read path already /// reads it as a String — not as a folder holding a DINT and an SINT[82]. /// [Fact] public async Task Discovered_STRING_struct_emits_one_String_variable_not_LEN_and_DATA() { var builder = await DiscoverWithTemplatesAsync( new() { [0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate) }, new AbCipDiscoveredTag("WW_WorkOrderNum", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: 0x0FCE)); var v = builder.Variables.ShouldHaveSingleItem(); v.Info.FullName.ShouldBe("WW_WorkOrderNum"); v.Info.DriverDataType.ShouldBe(DriverDataType.String); v.Info.IsArray.ShouldBeFalse(); builder.Folders.ShouldNotContain(f => f.BrowseName == "WW_WorkOrderNum"); } /// A nested STRING member of a UDT is one String leaf, addressable as Parent.Member. [Fact] public async Task Nested_STRING_member_emits_a_String_leaf() { var builder = await DiscoverWithTemplatesAsync( new() { [3386] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate), [0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate), }, new AbCipDiscoveredTag("Lookup", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: 3386)); var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info); byName.Keys.ShouldBe(["Lookup.Code", "Lookup.Desc", "Lookup.DataType", "Lookup.Lock"], ignoreOrder: true); byName["Lookup.Desc"].DriverDataType.ShouldBe(DriverDataType.String); byName["Lookup.Code"].DriverDataType.ShouldBe(DriverDataType.Int32); } /// /// Logix packs BOOL members into hidden host integers (ZZZZZZZZZZAlarms0) and AOIs carry /// __BitHost00-style backing members. Neither is user data, and a Logix name can never /// contain a double underscore, so both are dropped from the browse. /// [Fact] public async Task Hidden_BOOL_host_members_are_not_browsed() { var alarms = TemplateBufferBuilder.Build("Alarms", 4, ("ZZZZZZZZZZAlarms0", 0xC2, 0, 0), ("_00", 0xC1, 0, 0), ("__BitHost00", 0xC4, 0, 0)); var builder = await DiscoverWithTemplatesAsync( new() { [3425] = alarms }, new AbCipDiscoveredTag("Alarms", null, AbCipDataType.Structure, ReadOnly: false, TemplateInstanceId: 3425)); builder.Variables.Select(v => v.Info.FullName).ShouldBe(["Alarms._00"]); } /// /// An ARRAY of a UDT is not fanned out into Tag.Member leaves — those paths name no /// element and every read of them would fail. An array of STRING is a String array. /// [Fact] public async Task Discovered_struct_arrays_do_not_fan_out_and_STRING_arrays_are_String_arrays() { var udt = TemplateBufferBuilder.Build("Rec", 8, ("A", 0xC4, 0, 0), ("B", 0xCA, 0, 4)); var builder = await DiscoverWithTemplatesAsync( new() { [0x0FCE] = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate), [0x55] = udt, }, new AbCipDiscoveredTag("arrMessages", null, AbCipDataType.Structure, ReadOnly: false, ElementCount: 50, IsArray: true, TemplateInstanceId: 0x0FCE), new AbCipDiscoveredTag("Stored", null, AbCipDataType.Structure, ReadOnly: false, ElementCount: 255, IsArray: true, TemplateInstanceId: 0x55)); builder.Variables.ShouldNotContain(v => v.Info.FullName.StartsWith("Stored.")); var messages = builder.Variables.Single(v => v.Info.FullName == "arrMessages").Info; messages.DriverDataType.ShouldBe(DriverDataType.String); messages.IsArray.ShouldBeTrue(); messages.ArrayDim.ShouldBe(50u); } // ---- helpers ---- /// Test implementation of IAddressSpaceBuilder that records calls. private sealed class RecordingBuilder : IAddressSpaceBuilder { /// Gets the list of recorded folders. public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); /// Gets the list of recorded variables. public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); /// Records a folder node. /// The browse name of the folder. /// The display name of the folder. public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// Records a variable node. /// The browse name of the variable. /// The display name of the variable. /// The attribute information for the variable. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } /// Adds a property (no-op in test). /// Property name (unused in test). /// Property data type (unused in test). /// Property value (unused in test). public void AddProperty(string _, DriverDataType __, object? ___) { } /// Test variable handle. private sealed class Handle(string fullRef) : IVariableHandle { /// Gets the full reference of the variable. public string FullReference => fullRef; /// Marks the variable as an alarm condition. /// The alarm condition information. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } /// Null sink for alarm conditions. private sealed class NullSink : IAlarmConditionSink { /// Handles alarm transition (no-op). /// The alarm event arguments. public void OnTransition(AlarmEventArgs args) { } } } /// Fake enumerator factory for testing. private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory { private readonly AbCipDiscoveredTag[] _tags; /// Gets the last captured device parameters. public AbCipTagCreateParams? LastDeviceParams { get; private set; } /// Initializes a new instance of the FakeEnumeratorFactory. /// The tags to enumerate. public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags; /// Creates a new fake enumerator. public IAbCipTagEnumerator Create() => new FakeEnumerator(this); /// Fake tag enumerator for testing. private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator { /// Enumerates discovered tags asynchronously. /// The device parameters for enumeration. /// The cancellation token. public async IAsyncEnumerable EnumerateAsync( AbCipTagCreateParams deviceParams, [EnumeratorCancellation] CancellationToken cancellationToken) { outer.LastDeviceParams = deviceParams; await Task.CompletedTask; foreach (var t in outer._tags) yield return t; } /// Disposes the enumerator. public void Dispose() { } } } /// /// Fake 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. /// private sealed class IdKeyedTemplateReaderFactory : IAbCipTemplateReaderFactory { /// Gets the template blobs keyed by template instance id. public Dictionary Templates { get; } = new(); /// Gets the last template id any reader was asked for. public uint? LastTemplateId { get; private set; } /// Gets every template id any reader was asked for, in request order. public List RequestedIds { get; } = new(); /// Gets how many per-call readers were created. public int PerCallReaders { get; private set; } /// Gets how many batch readers were created. public int BatchReaders { get; private set; } /// Gets how many batch readers have been disposed. public int BatchReadersDisposed { get; set; } /// Creates a reader over the shared template map. public IAbCipTemplateReader Create() { PerCallReaders++; return new Reader(this); } /// Creates a batch reader (disposal is counted). public IAbCipTemplateReader CreateBatch() { BatchReaders++; return new Reader(this, batch: true); } private sealed class Reader(IdKeyedTemplateReaderFactory outer, bool batch = false) : IAbCipTemplateReader { public Task 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() { if (batch) outer.BatchReadersDisposed++; } } } /// /// Build a template buffer in libplctag's real @udt/id layout via /// . info is the member's CIP type word; arr /// > 0 marks an array member. /// private static byte[] BuildSimpleTemplate( string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members) => TemplateBufferBuilder.Build(name, instanceSize, [.. members.Select(m => (m.n, m.info, m.arr, m.off))]); }