Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs
Joseph Doherty 60b8d6f2d0 AB CIP PR 9-12 — Per-PLC-family profile tests + GuardLogix safety-tag support. Consolidates PRs 9/10/11/12 from the plan (ControlLogix / CompactLogix / Micro800 / GuardLogix integration suites) into a single PR because the per-family work that actually ships without a live ab_server binary is profile-metadata assertion + unit-level driver-option binding. Per-family integration tests that require a running simulator are deferred to the ab_server-CI follow-up already tracked from PR 3 (download prebuilt Windows binary as GitHub release asset). ControlLogix — baseline profile asserted (controllogix attribute, 4002 LFO ConnectionSize, 1,0 default path, request-packing + connected-messaging, 4000B max fragment). CompactLogix — narrower 504 ConnectionSize for 5069-L3x safety, 500B max fragment, lib attribute compactlogix which libplctag maps to the ControlLogix family internally but via our profile chain we surface it as a distinct knob so future quirk handling (5069 narrow-window regression cases) hangs off the compactlogix attribute. Micro800 — empty CIP path for no-backplane routing, 488B ConnectionSize, 484B fragment cap, request packing + connected messaging both disabled (most models reject Forward_Open), micro800 lib attribute. Test asserts the driver correctly parses an ab://192.168.1.20/ host address with empty path + forwards the empty path through AbCipTagCreateParams so libplctag sees the unconnected-only configuration. GuardLogix — wire protocol identical to ControlLogix (safety partition is a per-tag concern, not a wire-layer distinction) so profile defaults match ControlLogix. New AbCipTagDefinition.SafetyTag field — when true, the driver forces SecurityClassification.ViewOnly in discovery regardless of the Writable flag, and IWritable rejects the write upfront with BadNotWritable. Matches the Rockwell safety-partition isolation model where non-safety-task writes to safety tags would be rejected by the PLC anyway — surfacing the intent at the driver surface prevents wasted wire round-trips + gives Admin UI users a correct ViewOnly rendering. 14 new unit tests in AbCipPlcFamilyTests covering — ControlLogix profile defaults + correct profile selection at Initialize, CompactLogix narrower-than-ControlLogix ConnectionSize + fragment cap, Micro800 empty path parses + SupportsConnectedMessaging=false + SupportsRequestPacking=false + read forwards empty path + micro800 attribute through to libplctag, GuardLogix wire-protocol parity with ControlLogix, GuardLogix safety tag surfaces as ViewOnly in discovery even when Writable=true, GuardLogix safety-tag write rejected with BadNotWritable even when Writable=true, ForFamily theory (4 families → correct libplctag attribute). Total AbCip unit tests now 161/161 passing (+14 from PR 8's 147). Modbus + other drivers untouched; full solution builds 0 errors. PR 13 (IAlarmSource via tag-projected ALMA/ALMD blocks) remains deferred per the plan — feature-flagged pattern not needed before go-live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:18:51 -04:00

210 lines
8.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipPlcFamilyTests
{
// ---- ControlLogix ----
[Fact]
public void ControlLogix_profile_defaults_match_large_forward_open_baseline()
{
var p = AbCipPlcFamilyProfile.ControlLogix;
p.LibplctagPlcAttribute.ShouldBe("controllogix");
p.DefaultConnectionSize.ShouldBe(4002); // LFO — FW20+
p.DefaultCipPath.ShouldBe("1,0");
p.SupportsRequestPacking.ShouldBeTrue();
p.SupportsConnectedMessaging.ShouldBeTrue();
p.MaxFragmentBytes.ShouldBe(4000);
}
[Fact]
public async Task ControlLogix_device_initialises_with_correct_profile()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.LibplctagPlcAttribute.ShouldBe("controllogix");
}
// ---- CompactLogix ----
[Fact]
public void CompactLogix_profile_uses_narrower_connection_size()
{
var p = AbCipPlcFamilyProfile.CompactLogix;
p.LibplctagPlcAttribute.ShouldBe("compactlogix");
p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety
p.DefaultCipPath.ShouldBe("1,0");
p.SupportsRequestPacking.ShouldBeTrue();
p.SupportsConnectedMessaging.ShouldBeTrue();
p.MaxFragmentBytes.ShouldBe(500);
}
[Fact]
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.10/1,0", AbCipPlcFamily.CompactLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile;
profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes);
}
// ---- Micro800 ----
[Fact]
public void Micro800_profile_is_unconnected_only_with_empty_path()
{
var p = AbCipPlcFamilyProfile.Micro800;
p.LibplctagPlcAttribute.ShouldBe("micro800");
p.DefaultConnectionSize.ShouldBe(488);
p.DefaultCipPath.ShouldBe(""); // no backplane routing
p.SupportsRequestPacking.ShouldBeFalse();
p.SupportsConnectedMessaging.ShouldBeFalse();
p.MaxFragmentBytes.ShouldBe(484);
}
[Fact]
public async Task Micro800_device_with_empty_cip_path_parses_correctly()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var state = drv.GetDeviceState("ab://192.168.1.20/")!;
state.ParsedAddress.CipPath.ShouldBe("");
state.Profile.SupportsRequestPacking.ShouldBeFalse();
state.Profile.SupportsConnectedMessaging.ShouldBeFalse();
}
[Fact]
public async Task Micro800_read_forwards_empty_path_to_tag_create_params()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Value = 123 } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["X"].CreationParams.CipPath.ShouldBe("");
factory.Tags["X"].CreationParams.LibplctagPlcAttribute.ShouldBe("micro800");
}
// ---- GuardLogix ----
[Fact]
public void GuardLogix_profile_wire_protocol_mirrors_ControlLogix()
{
var p = AbCipPlcFamilyProfile.GuardLogix;
// Wire protocol is identical to ControlLogix — only the safety-partition semantics differ,
// which is a per-tag concern surfaced via AbCipTagDefinition.SafetyTag.
p.LibplctagPlcAttribute.ShouldBe("controllogix");
p.DefaultConnectionSize.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
p.DefaultCipPath.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath);
}
[Fact]
public async Task GuardLogix_safety_tag_surfaces_as_ViewOnly_in_discovery()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt),
new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "NormalTag").Info.SecurityClass
.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "SafetyTag").Info.SecurityClass
.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task GuardLogix_safety_tag_writes_rejected_even_when_Writable_is_true()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("SafetySet", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
// ---- ForFamily dispatch ----
[Theory]
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")]
public void ForFamily_dispatches_to_correct_profile(AbCipPlcFamily family, string expectedAttribute)
{
AbCipPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
}
// ---- 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) { } }
}
}