using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; [Trait("Category", "Unit")] public sealed class TwinCATSymbolBrowserTests { /// Verifies that discovery without EnableControllerBrowse only emits predeclared tags. [Fact] public async Task Discovery_without_EnableControllerBrowse_emits_only_predeclared() { var builder = new RecordingBuilder(); var factory = new FakeTwinCATClientFactory { Customise = () => { var c = new FakeTwinCATClient(); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Hidden", TwinCATDataType.DInt, false)); return c; }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = false, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Variables.Select(v => v.BrowseName).ShouldBe(["Declared"]); builder.Folders.ShouldNotContain(f => f.BrowseName == "Discovered"); } /// Verifies that discovery with browse enabled adds controller symbols under Discovered folder. [Fact] public async Task Discovery_with_browse_enabled_adds_controller_symbols_under_Discovered_folder() { var builder = new RecordingBuilder(); var factory = new FakeTwinCATClientFactory { Customise = () => { var c = new FakeTwinCATClient(); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, ReadOnly: false)); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("GVL.Setpoint", TwinCATDataType.Real, ReadOnly: false)); return c; }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = true, }, "drv-1", factory); 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("MAIN.Counter"); builder.Variables.Select(v => v.Info.FullName).ShouldContain("GVL.Setpoint"); } /// Verifies that browse filters out system symbols correctly. [Fact] public async Task Browse_filters_system_symbols() { var builder = new RecordingBuilder(); var factory = new FakeTwinCATClientFactory { Customise = () => { var c = new FakeTwinCATClient(); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("TwinCAT_SystemInfoVarList._AppInfo", TwinCATDataType.DInt, false)); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Constants.PI", TwinCATDataType.LReal, true)); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Mc_InternalState", TwinCATDataType.DInt, true)); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("__CompilerGen", TwinCATDataType.DInt, true)); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Real", TwinCATDataType.DInt, false)); return c; }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = true, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Real"]); } /// Verifies that browse skips symbols with null datatype. [Fact] public async Task Browse_skips_symbols_with_null_datatype() { var builder = new RecordingBuilder(); var factory = new FakeTwinCATClientFactory { Customise = () => { var c = new FakeTwinCATClient(); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Struct", DataType: null, ReadOnly: false)); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, false)); return c; }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = true, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Counter"]); } /// Verifies that read-only symbols surface as ViewOnly. [Fact] public async Task ReadOnly_symbol_surfaces_ViewOnly() { var builder = new RecordingBuilder(); var factory = new FakeTwinCATClientFactory { Customise = () => { var c = new FakeTwinCATClient(); c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Status", TwinCATDataType.DInt, ReadOnly: true)); return c; }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = true, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } /// Verifies that browse failure is non-fatal and predeclared tags still emit. [Fact] public async Task Browse_failure_is_non_fatal_predeclared_still_emits() { var builder = new RecordingBuilder(); var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnBrowse = true }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = true, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Variables.Select(v => v.BrowseName).ShouldContain("Declared"); } /// Verifies that system symbol filter matches expected patterns. /// The symbol path to test. /// Whether the path is expected to be a system symbol. [Theory] [InlineData("TwinCAT_SystemInfoVarList._AppInfo", true)] [InlineData("TwinCAT_RuntimeInfo.Something", true)] [InlineData("Constants.PI", true)] [InlineData("Mc_AxisState", true)] [InlineData("__hidden", true)] [InlineData("Global_Version", true)] [InlineData("MAIN.UserVar", false)] [InlineData("GVL.Counter", false)] [InlineData("MyFbInstance.State", false)] [InlineData("", true)] [InlineData(" ", true)] public void SystemSymbolFilter_matches_expected_patterns(string path, bool expected) { TwinCATSystemSymbolFilter.IsSystemSymbol(path).ShouldBe(expected); } // ---- helpers ---- private sealed class RecordingBuilder : IAddressSpaceBuilder { /// /// Gets the list of folders that were added to the address space. /// public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); /// /// Gets the list of variables that were added to the address space. /// public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); /// /// Adds a folder to the address space builder. /// /// The browse name of the folder. /// The display name of the folder. /// This builder for method chaining. public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } /// /// Adds a variable to the address space builder. /// /// The browse name of the variable. /// The display name of the variable. /// The driver attribute information for the variable. /// A variable handle for further configuration. public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } /// /// Adds a property to the address space builder. /// /// The property name (unused). /// The property data type (unused). /// The property value (unused). public void AddProperty(string _, DriverDataType __, object? ___) { } 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. /// An alarm condition sink for event transitions. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } /// /// A null implementation of IAlarmConditionSink for test purposes. /// private sealed class NullSink : IAlarmConditionSink { /// /// Handles alarm transitions (no-op for tests). /// /// The alarm event arguments. public void OnTransition(AlarmEventArgs args) { } } } }