using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// Family-aware addressing: PLC-5 octal I/O (#244), MicroLogix function files (#245), indirect (#247). [Trait("Category", "Unit")] public sealed class AbLegacyFamilyAddressingTests { // ---- #244 PLC-5 octal I/O ---- /// PLC-5 I/O word and bit indices parse as octal and are stored decimal. [Theory] [InlineData("I:017/07", "I", 15, 7, "I:15/7")] [InlineData("I:017/17", "I", 15, 15, "I:15/15")] [InlineData("O:010/10", "O", 8, 8, "O:8/8")] [InlineData("I:000", "I", 0, null, "I:0")] [InlineData("I:12", "I", 10, null, "I:10")] public void Plc5_io_indices_are_octal(string text, string letter, int word, int? bit, string libplctag) { var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldNotBeNull(); a.FileLetter.ShouldBe(letter); a.WordNumber.ShouldBe(word); a.BitIndex.ShouldBe(bit); a.ToLibplctagName().ShouldBe(libplctag); } /// The decimal-normalised name round-trips family-neutrally, so the native transport reads back the same numbers. [Fact] public void Plc5_octal_address_round_trips_through_the_family_neutral_parser() { var a = AbLegacyAddress.TryParse("I:017/07", AbLegacyPlcFamily.Plc5)!; AbLegacyAddress.TryParse(a.ToLibplctagName()).ShouldBe(a); } /// A digit 8 or 9 is not octal: a clear parse failure, not a silent misread. [Theory] [InlineData("I:018")] [InlineData("I:019/07")] [InlineData("I:017/08")] [InlineData("O:9/1")] [InlineData("O:1/9")] public void Plc5_io_rejects_8_and_9(string text) => AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.Plc5).ShouldBeNull(); /// Control: the same text under SLC / MicroLogix / Logix-PCCC / no family is decimal. [Theory] [InlineData(AbLegacyPlcFamily.Slc500)] [InlineData(AbLegacyPlcFamily.MicroLogix)] [InlineData(AbLegacyPlcFamily.LogixPccc)] public void Non_plc5_io_stays_decimal(AbLegacyPlcFamily family) { AbLegacyAddress.TryParse("I:12/9", family).ShouldNotBeNull().WordNumber.ShouldBe(12); AbLegacyAddress.TryParse("I:12/9", family)!.BitIndex.ShouldBe(9); AbLegacyAddress.TryParse("I:12/9").ShouldNotBeNull().WordNumber.ShouldBe(12); } /// PLC-5 N-files (and bit indices on them) stay decimal — only I/O is octal. [Fact] public void Plc5_non_io_files_stay_decimal() { var a = AbLegacyAddress.TryParse("N7:12/9", AbLegacyPlcFamily.Plc5).ShouldNotBeNull(); a.WordNumber.ShouldBe(12); a.BitIndex.ShouldBe(9); } // ---- #245 MicroLogix function files ---- /// MicroLogix parses the function-file letters, with their sub-elements. [Theory] [InlineData("RTC:0.HR", "RTC", 0, "HR")] [InlineData("HSC:0.ACC", "HSC", 0, "ACC")] [InlineData("pto:0.EN", "PTO", 0, "EN")] [InlineData("STI:0.SPM", "STI", 0, "SPM")] [InlineData("BHI:0.FRN", "BHI", 0, "FRN")] public void MicroLogix_parses_function_files(string text, string letter, int element, string sub) { var a = AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull(); a.FileLetter.ShouldBe(letter); a.FileNumber.ShouldBeNull(); a.WordNumber.ShouldBe(element); a.SubElement.ShouldBe(sub); } /// Function files are MicroLogix-only; every other family and the neutral grammar reject them. [Theory] [InlineData(AbLegacyPlcFamily.Slc500)] [InlineData(AbLegacyPlcFamily.Plc5)] [InlineData(AbLegacyPlcFamily.LogixPccc)] public void Other_families_reject_function_files(AbLegacyPlcFamily family) { AbLegacyAddress.TryParse("RTC:0.HR", family).ShouldBeNull(); AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull(); } /// A function file is a fixed single instance and not bit-addressable. [Theory] [InlineData("RTC1:0.HR")] [InlineData("RTC:0/3")] [InlineData("HSC:0.ACC/2")] public void MicroLogix_function_file_shape_is_validated(string text) => AbLegacyAddress.TryParse(text, AbLegacyPlcFamily.MicroLogix).ShouldBeNull(); /// The catalogue maps flags to Boolean, numeric members to Int32, and unknown members to Int32. [Theory] [InlineData("RTC", "HR", DriverDataType.Int32)] [InlineData("RTC", "BL", DriverDataType.Boolean)] [InlineData("HSC", "ACC", DriverDataType.Int32)] [InlineData("HSC", "DN", DriverDataType.Boolean)] [InlineData("PTO", "OUT", DriverDataType.Boolean)] [InlineData("STI", "SPM", DriverDataType.Int32)] [InlineData("RTC", "NOPE", DriverDataType.Int32)] public void Function_file_catalogue(string file, string sub, DriverDataType expected) => AbLegacyFunctionFile.SubElementType(file, sub).ShouldBe(expected); // ---- #247 indirect addressing ---- /// Both indirect forms parse, carry their inner address, and round-trip their text. [Theory] [InlineData("N7:[N7:0]", false, "N7:[N7:0]")] [InlineData("N[N7:0]:5", true, "N[N7:0]:5")] [InlineData("N7:[T4:1.ACC]", false, "N7:[T4:1.ACC]")] [InlineData("B3:[N7:0]/4", false, "B3:[N7:0]/4")] public void Indirect_forms_parse(string text, bool fileIndirect, string roundTrip) { var a = AbLegacyAddress.TryParse(text).ShouldNotBeNull(); a.IsIndirect.ShouldBeTrue(); (a.IndirectFileSource is not null).ShouldBe(fileIndirect); (a.IndirectWordSource is not null).ShouldBe(!fileIndirect); a.ToLibplctagName().ShouldBe(roundTrip); } /// Nested indirection, empty or invalid inner addresses, and unbalanced brackets are rejected. [Theory] [InlineData("N7:[N[N7:0]:1]")] [InlineData("N7:[]")] [InlineData("N7:[Q9:0]")] [InlineData("N7:[N7:0")] [InlineData("N7:N7:0]")] [InlineData("N7:[N7:[N7:0]]")] public void Malformed_indirect_is_rejected(string text) => AbLegacyAddress.TryParse(text).ShouldBeNull(); /// A direct address is not indirect. [Fact] public void Direct_address_is_not_indirect() => AbLegacyAddress.TryParse("N7:3/2")!.IsIndirect.ShouldBeFalse(); // ---- driver level ---- private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver( AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags) { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)], RawTags = AbLegacyRawTags.Entries(tags), }, "drv-1", factory); return (drv, factory); } /// A PLC-5 octal I/O tag reaches the transport as the decimal name, and its bit decodes at the octal-parsed index. [Fact] public async Task Plc5_octal_io_tag_is_handed_to_the_transport_decimal() { var (drv, factory) = NewDriver(AbLegacyPlcFamily.Plc5, new AbLegacyTagDefinition("In", "ab://10.0.0.5/1,0", "I:017/17", AbLegacyDataType.Bit)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = true }; var snap = (await drv.ReadAsync(["In"], CancellationToken.None)).Single(); snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); factory.Tags.ContainsKey("I:15/15").ShouldBeTrue(); factory.Tags["I:15/15"].ReadCount.ShouldBe(1); } /// A MicroLogix function-file tag reaches the transport under its libplctag name. [Fact] public async Task MicroLogix_function_file_tag_reaches_the_transport() { var (drv, factory) = NewDriver(AbLegacyPlcFamily.MicroLogix, new AbLegacyTagDefinition("Hour", "ab://10.0.0.5/1,0", "RTC:0.HR", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 14 }; var snap = (await drv.ReadAsync(["Hour"], CancellationToken.None)).Single(); snap.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); snap.Value.ShouldBe(14); factory.Tags.ContainsKey("RTC:0.HR").ShouldBeTrue(); } // ---- native transport ---- private sealed class NativeHarness : IAsyncDisposable { private readonly FakeCipChannel _channel = new(); private readonly RequestScheduler _scheduler; public NativeAbLegacyTagRuntime Runtime { get; } public NativeHarness(string plc, string tag) { _scheduler = new RequestScheduler(_channel, allowPacking: false); Runtime = new NativeAbLegacyTagRuntime( new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(2), 1), _scheduler, 0x1234, 0x01020304); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } /// The native transport refuses function files and indirect names as Unsupported rather than mis-encoding them. [Theory] [InlineData("RTC:0.HR", "function file")] [InlineData("N7:[N7:0]", "indirect")] public async Task Native_transport_refuses_function_file_and_indirect_as_Unsupported(string tag, string why) { await using var h = new NativeHarness("micrologix", tag); var ex = await Should.ThrowAsync(() => h.Runtime.InitializeAsync(CancellationToken.None)); ex.Status.ShouldBe(AbStatus.Unsupported); ex.Message.ShouldContain(why, Case.Insensitive); } private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger { public readonly System.Collections.Concurrent.ConcurrentQueue Warnings = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; public void Log(Microsoft.Extensions.Logging.LogLevel level, Microsoft.Extensions.Logging.EventId id, TState state, Exception? ex, Func f) { if (level == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Enqueue(f(state, ex)); } } /// /// A PLC-5 address that no longer parses (the pre-octal-era decimal 8/9 digit) is a config error: BadNodeIdUnknown on /// read and write, no runtime created, health untouched, and ONE warning per tag however often it is polled. /// [Fact] public async Task Malformed_address_is_BadNodeIdUnknown_warned_once_and_never_throws() { var factory = new FakeAbLegacyTagFactory(); var logger = new CapturingLogger(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)], RawTags = AbLegacyRawTags.Entries( new AbLegacyTagDefinition("Bad", "ab://10.0.0.5/1,0", "I:018", AbLegacyDataType.Int, Writable: true), new AbLegacyTagDefinition("Good", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int)), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-1", factory, logger); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 }; var first = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); var second = await drv.ReadAsync(["Bad"], CancellationToken.None); var write = await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None); first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag factory.Tags.Keys.ShouldNotContain(k => k.StartsWith("I:")); logger.Warnings.Count(w => w.Contains("not valid for the device's PLC family") && w.Contains("I:018")).ShouldBe(1); } /// The connectivity-probe address is normalised like tag paths (PLC-5 octal I/O handed over decimal). [Fact] public async Task Probe_address_is_normalised_like_tag_paths() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)], Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(200), ProbeAddress = "I:017", }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var deadline = DateTime.UtcNow.AddSeconds(5); while (!factory.Tags.ContainsKey("I:15") && DateTime.UtcNow < deadline) await Task.Delay(20); await drv.ShutdownAsync(CancellationToken.None); factory.Tags.ContainsKey("I:15").ShouldBeTrue(); factory.Tags.ContainsKey("I:017").ShouldBeFalse(); } }