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