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() { }
}
}
}