Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
Joseph Doherty 447086892e AB CIP PR 5 — ITagDiscovery (pre-declared emission + controller-enumeration scaffolding). DiscoverAsync streams tags to IAddressSpaceBuilder with the same shape the Modbus driver uses, keyed by device host address so one driver instance exposing N PLCs produces N device folders under a shared "AbCip" root. Pre-declared tags from AbCipDriverOptions.Tags emit first, filtered through AbCipSystemTagFilter so __DEFVAL_* / __DEFAULT_* / Routine: / Task: / Local:N:X / Map: / Axis: / Cam: / MotionGroup: infrastructure names never reach the address space. Writable tags map to SecurityClassification.Operate, non-writable to ViewOnly. Controller enumeration (walking the Logix Symbol Object via @tags) is wired up through a new IAbCipTagEnumerator + IAbCipTagEnumeratorFactory abstraction — default EmptyAbCipTagEnumeratorFactory returns an empty sequence so the driver stays production-safe without a real decoder. Tests inject FakeEnumeratorFactory to exercise the discovered-tag path: discovered tags land under a Discovered/ sub-folder, program-scope produces Program:P.Name full references, the IsSystemTag hint + the AbCipSystemTagFilter both act as gates, ReadOnly surfaces SecurityClassification.ViewOnly. The real @tags walker is a follow-up because libplctag 1.5.2 (latest stable on NuGet) does not expose TagInfoPlcMapper / UdtInfoMapper — the DataTypes namespace only ships IPlcMapper<T>, so enumerating the Symbol Object requires either implementing a custom IPlcMapper for the CIP byte layout or raw-buffer decoding via plc_tag_get_raw — both non-trivial enough to warrant their own PR. Code comment on EmptyAbCipTagEnumerator documents the gap + points to the follow-up. AbCipTemplateCache placeholder ships with a ConcurrentDictionary<(device, templateInstanceId), AbCipUdtShape> + Put / TryGet / Clear / Count — the Template Object reader (CIP class 0x6C) populates it in PR 6 and FlushOptionalCachesAsync now clears it. AbCipUdtShape + AbCipUdtMember records describe UDT layout — type name + total size + ordered members with offset / type / array length. AbCipDriver ctor gains optional enumeratorFactory parameter matching the tagFactory pattern from PR 3. TemplateCache exposed internally for PR 6's reader to write into. 25 new unit tests in AbCipDriverDiscoveryTests covering — pre-declared emission under device folder, DeviceName fallback to host address, system-tag filter rejecting pre-declared infrastructure names, cross-device tag filtering (tags for a device this driver does not own are ignored), controller enumeration adds tags under Discovered/, system-tag hint + filter both enforced, ReadOnly → ViewOnly, AbCipTagCreateParams composition (gateway / port / CIP path / libplctag attribute / tag name "@tags" / timeout), default enumerator factory used when not injected, 13 Theory cases covering every AbCipSystemTagFilter pattern, template cache roundtrip + clear, FlushOptionalCachesAsync clears the cache. Total AbCip unit tests now 123/123 passing (+25 from PR 4's 98). Modbus + other existing tests untouched; full solution builds 0 errors. Unblocks PR 6 (UDT structured read/write) + PR 7 (subscriptions consuming PollGroupEngine from PR 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:05:02 -04:00

279 lines
11 KiB
C#

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
{
[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);
}
[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");
}
[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"]);
}
[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();
}
[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")],
}, "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");
}
[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")],
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
}
[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")],
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[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),
}, "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));
}
[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();
}
[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);
}
[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);
}
[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 ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _tags;
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
{
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;
}
public void Dispose() { }
}
}
}