using System.Runtime.CompilerServices; 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")], Tags = [ 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); } /// 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")], Tags = [ 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")], Tags = [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"); } /// 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); } /// Verifies that FlushOptionalCachesAsync clears the template cache. [Fact] public async Task FlushOptionalCachesAsync_clears_template_cache() { var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1"); drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, [])); drv.TemplateCache.Count.ShouldBe(1); await drv.FlushOptionalCachesAsync(CancellationToken.None); drv.TemplateCache.Count.ShouldBe(0); } // ---- 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() { } } } }