Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTagPathTests.cs
Joseph Doherty 3e0452e8a4 AB CIP PR 2 — scaffolding + Core (AbCipDriver skeleton + libplctag binding + host / tag-path / data-type / status-code parsers + per-family profiles + SafeHandle wrapper + test harness). Ships everything needed to stand up the driver project as a compiling assembly with no wire calls yet — PR 3 adds IReadable against ab_server which is the first PR that actually touches the native library. Project reference shape matches Modbus / OpcUaClient / S7 (only Core.Abstractions, no Core / Configuration / Polly) so the driver stays lean and doesn't drag EF Core into every deployment that wants AB support. libplctag 1.5.2 pinned (1.6.x only exists as alpha — stable 1.5 series covers ControlLogix / CompactLogix / Micro800 / SLC500 / PLC-5 / MicroLogix which matches plan decision #11 family coverage). libplctag.NativeImport arrives transitively. AbCipHostAddress parses ab://gateway[:port]/cip-path canonical strings end-to-end: handles hostname or IP gateway, optional explicit port (default 44818 EtherNet-IP reserved), CIP path including bridged routes (1,2,2,10.0.0.10,1,0), empty path for Micro800 / MicroLogix without backplane routing, case-insensitive scheme, default-port stripping in canonical form for round-trip stability. Opaque string survives straight into libplctag's gateway / path attributes so no translation layer at wire time. AbCipTagPath handles the full Logix symbolic tag surface — controller-scope (Motor1_Speed), program-scope (Program:MainProgram.StepIndex), structured member access (Motor1.Speed.Setpoint), multi-dim array subscripts (Matrix[1,2,3]), bit-within-DINT via .N syntax (Flags.3, Motor.Status.12) with valid range 0-31 per Logix 5000 General Instructions Reference. Structural capture so PR 6 UDT work can walk the path against a cached template without reparsing. Rejects malformed shapes (empty scopes, ident starting with digit, spaces, empty/negative/non-numeric subscripts, unbalanced brackets, leading / trailing dots). Round-trips via ToLibplctagName producing the exact string libplctag's name attribute expects. AbCipDataType mirrors ModbusDataType shape — atomic Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / String / Dt plus a Structure marker for UDT-typed tags (resolved via CIP Template Object at discovery time in PR 5/6). ToDriverDataType adapter follows the Modbus widening convention for unsigned + 64-bit until DriverDataType picks those up. AbCipStatusMapper covers the CIP general-status values an AB PLC actually returns during normal operation (0x00/0x04/0x05/0x06/0x08/0x0A/0x0B/0x0E/0x10/0x13/0x16) + libplctag PLCTAG_STATUS_* codes (0, >0 pending, negative error families). Mirrors ModbusDriver.MapModbusExceptionToStatus so Admin UI status displays stay uniform across drivers. PlcTagHandle is a SafeHandle around the int32 native tag ID with plc_tag_destroy slot wired as a no-op for PR 2 (P/Invoke DllImport arrives with PR 3 when the wire calls land). Lifetime guaranteed by the SafeHandle finalizer — every leaked handle gets cleaned up even when the owner is GC'd without explicit Dispose. IsInvalid when native ID <= 0 so destroying a negative (error) handle never happens. Critical because driver-specs.md §3 flags libplctag native heap as invisible to GetMemoryFootprint — leaked handles directly feed the Tier-B recycle trigger. AbCipDriverOptions captures the multi-device shape — one driver instance can talk to N PLCs via Devices[] (each with HostAddress + PlcFamily + optional DeviceName); Tags[] references devices by HostAddress as the cross-key; AbCipProbeOptions + driver-wide Timeout. AbCipDriver implements IDriver only — InitializeAsync parses every device's HostAddress and selects its PlcFamilyProfile (fails fast on malformed strings via InvalidOperationException → Faulted health), per-device state cached in a DeviceState record with parsed address + profile + empty TagHandles dict for later PRs. ReinitializeAsync is the Tier-B escape hatch — shuts down every device, disposes every PlcTagHandle via SafeHandle lifetime, reinitializes from options. ShutdownAsync clears the device dict and flips health to Unknown. PlcFamilies/AbCipPlcFamilyProfile gives four baseline profiles — ControlLogix (4002 ConnectionSize, path 1,0, Large Forward Open + request packing + connected messaging, FW20+ baseline), CompactLogix (narrower 504 default for 5069-L3x safety), Micro800 (488 cap, empty path, unconnected-only, no request packing), GuardLogix (shares ControlLogix wire protocol — safety partition is tag-level, surfaced as ViewOnly in PR 12). Tests — 76 new cases across 4 test classes — AbCipHostAddressTests (10 valid shapes, 10 invalid shapes, ToString canonicalization, round-trip stability), AbCipTagPathTests (18 cases including multi-scope / multi-member / multi-subscript / bit-in-DINT / rejected shapes / underscore idents / round-trip), AbCipStatusMapperTests (12 CIP + 8 libplctag codes), AbCipDriverTests (IDriver lifecycle + multi-device init + malformed-address fault + per-family profile lookup + PlcTagHandle invalid/dispose idempotency + AbCipDataType mapping). Full solution builds 0 errors; 254 warnings are pre-existing xUnit1051 CancellationToken hints outside this PR. Solution file updated to include both new projects. Unblocks PR 3 (IReadable against ab_server) which is the first PR to exercise the native library end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:58:15 -04:00

147 lines
4.6 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipTagPathTests
{
[Fact]
public void Controller_scope_single_segment()
{
var p = AbCipTagPath.TryParse("Motor1_Speed");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBeNull();
p.Segments.Count.ShouldBe(1);
p.Segments[0].Name.ShouldBe("Motor1_Speed");
p.Segments[0].Subscripts.ShouldBeEmpty();
p.BitIndex.ShouldBeNull();
p.ToLibplctagName().ShouldBe("Motor1_Speed");
}
[Fact]
public void Program_scope_parses()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.StepIndex");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Single().Name.ShouldBe("StepIndex");
p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex");
}
[Fact]
public void Structured_member_access_splits_segments()
{
var p = AbCipTagPath.TryParse("Motor1.Speed.Setpoint");
p.ShouldNotBeNull();
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Speed", "Setpoint"]);
p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint");
}
[Fact]
public void Single_dim_array_subscript()
{
var p = AbCipTagPath.TryParse("Data[7]");
p.ShouldNotBeNull();
p.Segments.Single().Name.ShouldBe("Data");
p.Segments.Single().Subscripts.ShouldBe([7]);
p.ToLibplctagName().ShouldBe("Data[7]");
}
[Fact]
public void Multi_dim_array_subscript()
{
var p = AbCipTagPath.TryParse("Matrix[1,2,3]");
p.ShouldNotBeNull();
p.Segments.Single().Subscripts.ShouldBe([1, 2, 3]);
p.ToLibplctagName().ShouldBe("Matrix[1,2,3]");
}
[Fact]
public void Bit_in_dint_captured_as_bit_index()
{
var p = AbCipTagPath.TryParse("Flags.3");
p.ShouldNotBeNull();
p.Segments.Single().Name.ShouldBe("Flags");
p.BitIndex.ShouldBe(3);
p.ToLibplctagName().ShouldBe("Flags.3");
}
[Fact]
public void Bit_in_dint_after_member()
{
var p = AbCipTagPath.TryParse("Motor.Status.12");
p.ShouldNotBeNull();
p.Segments.Select(s => s.Name).ShouldBe(["Motor", "Status"]);
p.BitIndex.ShouldBe(12);
p.ToLibplctagName().ShouldBe("Motor.Status.12");
}
[Fact]
public void Bit_index_32_rejected_out_of_range()
{
// 32 exceeds the DINT bit width — treated as a member name rather than bit selector,
// which fails ident validation and returns null.
AbCipTagPath.TryParse("Flags.32").ShouldBeNull();
}
[Fact]
public void Program_scope_with_members_and_subscript_and_bit()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors[0].Status.5");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Status"]);
p.Segments[0].Subscripts.ShouldBe([0]);
p.BitIndex.ShouldBe(5);
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("Program:")] // empty scope
[InlineData("Program:MP")] // no body after scope
[InlineData("1InvalidStart")] // ident starts with digit
[InlineData("Bad Name")] // space in ident
[InlineData("Motor[]")] // empty subscript
[InlineData("Motor[-1]")] // negative subscript
[InlineData("Motor[a]")] // non-numeric subscript
[InlineData("Motor[")] // unbalanced bracket
[InlineData("Motor.")] // trailing dot
[InlineData(".Motor")] // leading dot
public void Invalid_shapes_return_null(string? input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
[Fact]
public void Ident_with_underscore_accepted()
{
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
}
[Fact]
public void ToLibplctagName_recomposes_round_trip()
{
var cases = new[]
{
"Motor1_Speed",
"Program:Main.Counter",
"Array[5]",
"Matrix[1,2]",
"Obj.Member.Sub",
"Flags.0",
"Program:P.Obj[2].Flags.15",
};
foreach (var c in cases)
{
var parsed = AbCipTagPath.TryParse(c);
parsed.ShouldNotBeNull(c);
parsed.ToLibplctagName().ShouldBe(c);
}
}
}