using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// PR 7 — array contiguous block addressing. One libplctag tag with elem_count=N /// pulls N consecutive PCCC words in a single frame. Parser accepts both Rockwell `,N` /// and libplctag `[N]` suffixes; driver advertises /// and decodes the buffer element-by-element. /// [Trait("Category", "Unit")] public sealed class AbLegacyArrayTests { // ---- Parser positives ---- [Theory] [InlineData("N7:0,10", "N", 7, 0, 10)] [InlineData("N7:0[10]", "N", 7, 0, 10)] [InlineData("F8:0,5", "F", 8, 0, 5)] [InlineData("F8:0[5]", "F", 8, 0, 5)] [InlineData("B3:0,10", "B", 3, 0, 10)] [InlineData("L19:0,4", "L", 19, 0, 4)] [InlineData("N7:0,1", "N", 7, 0, 1)] [InlineData("N7:0,120", "N", 7, 0, 120)] public void TryParse_accepts_array_suffix(string input, string letter, int? file, int word, int expectedArrayCount) { var a = AbLegacyAddress.TryParse(input); a.ShouldNotBeNull(); a.FileLetter.ShouldBe(letter); a.FileNumber.ShouldBe(file); a.WordNumber.ShouldBe(word); a.ArrayCount.ShouldBe(expectedArrayCount); } [Fact] public void TryParse_no_suffix_leaves_ArrayCount_null() { var a = AbLegacyAddress.TryParse("N7:0"); a.ShouldNotBeNull(); a.ArrayCount.ShouldBeNull(); } // ---- Parser rejects ---- [Theory] [InlineData("N7:0,10/3")] // array + bit [InlineData("N7:0[10]/3")] // array + bit (bracket form) [InlineData("T4:0,5.ACC")] // array + sub-element [InlineData("T4:0[5].ACC")] // array + sub-element (bracket form) [InlineData("N7:0,121")] // over PCCC frame ceiling [InlineData("N7:0[121]")] // over PCCC frame ceiling (bracket form) [InlineData("N7:0,0")] // zero-element array [InlineData("N7:0,-1")] // negative array [InlineData("N7:0,abc")] // non-numeric array public void TryParse_rejects_bad_array_combinations(string input) { AbLegacyAddress.TryParse(input).ShouldBeNull(); } // ---- ToLibplctagName canonicalises to bracket form ---- [Theory] [InlineData("N7:0,10", "N7:0[10]")] [InlineData("N7:0[10]", "N7:0[10]")] [InlineData("F8:0,5", "F8:0[5]")] [InlineData("L19:0,4", "L19:0[4]")] public void ToLibplctagName_canonicalises_array_suffix_to_brackets(string input, string expectedLibplctag) { var a = AbLegacyAddress.TryParse(input); a.ShouldNotBeNull(); a.ToLibplctagName().ShouldBe(expectedLibplctag); } [Fact] public void ToLibplctagName_array_with_indirect_word() { // Indirect word source + array suffix — `N7:[N7:0][10]` reads 10 consecutive words // starting at the resolved indirect address. (Driver path still rejects indirect // addresses at create-time but the parser surface should keep them queryable.) var a = AbLegacyAddress.TryParse("N7:[N7:0][10]"); a.ShouldNotBeNull(); a.ArrayCount.ShouldBe(10); a.IndirectWordSource.ShouldNotBeNull(); a.ToLibplctagName().ShouldBe("N7:[N7:0][10]"); } // ---- Options override ---- [Fact] public void ResolveElementCount_explicit_ArrayLength_overrides_parsed_suffix() { // Address says ,10 but ArrayLength override pins to 20 — config wins over the // address suffix. var def = new AbLegacyTagDefinition( "X", "ab://h/1,0", "N7:0,10", AbLegacyDataType.Int, ArrayLength: 20); var parsed = AbLegacyAddress.TryParse(def.Address)!; AbLegacyDriver.ResolveElementCount(def, parsed).ShouldBe(20); } [Fact] public void ResolveElementCount_no_override_uses_parsed_suffix() { var def = new AbLegacyTagDefinition( "X", "ab://h/1,0", "N7:0,10", AbLegacyDataType.Int); var parsed = AbLegacyAddress.TryParse(def.Address)!; AbLegacyDriver.ResolveElementCount(def, parsed).ShouldBe(10); } [Fact] public void ResolveElementCount_no_override_no_suffix_returns_one() { var def = new AbLegacyTagDefinition( "X", "ab://h/1,0", "N7:0", AbLegacyDataType.Int); var parsed = AbLegacyAddress.TryParse(def.Address)!; AbLegacyDriver.ResolveElementCount(def, parsed).ShouldBe(1); } [Fact] public void ResolveElementCount_oversized_override_throws() { var def = new AbLegacyTagDefinition( "X", "ab://h/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 121); var parsed = AbLegacyAddress.TryParse(def.Address)!; Should.Throw(() => AbLegacyDriver.ResolveElementCount(def, parsed)); } [Fact] public void ResolveElementCount_zero_override_throws() { var def = new AbLegacyTagDefinition( "X", "ab://h/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 0); var parsed = AbLegacyAddress.TryParse(def.Address)!; Should.Throw(() => AbLegacyDriver.ResolveElementCount(def, parsed)); } // ---- DTO JSON round-trip ---- [Fact] public void DriverConfigJson_round_trips_ArrayLength() { const string json = """ { "Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "Slc500" } ], "Tags": [ { "Name": "Block", "DeviceHostAddress": "ab://10.0.0.5/1,0", "Address": "N7:0,10", "DataType": "Int", "ArrayLength": 20 } ] } """; var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-1", json); // Reflect into the live driver to confirm the tag definition carries the override. // Cleaner than serialising back out — the DTO → record flow is what we actually care about. // Using ReadAsync with a fake factory would also work but adds a mile of plumbing. // Here we just confirm the round-trip via the public ResolveElementCount surface. var parsedAddr = AbLegacyAddress.TryParse("N7:0,10")!; var def = new AbLegacyTagDefinition( "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int, ArrayLength: 20); AbLegacyDriver.ResolveElementCount(def, parsedAddr).ShouldBe(20); driver.ShouldNotBeNull(); } // ---- Runtime ElementCount plumbing ---- [Fact] public async Task EnsureTagRuntime_threads_ElementCount_through_to_factory() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition( "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValues = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], }; await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); // Tag name canonicalises to the libplctag bracket form; that's what shows up as the key. factory.Tags.ShouldContainKey("N7:0[10]"); factory.Tags["N7:0[10]"].CreationParams.ElementCount.ShouldBe(10); } [Fact] public async Task EnsureTagRuntime_uses_ArrayLength_override_when_set() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition( "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int, ArrayLength: 20)], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValues = Enumerable.Range(0, 20).Cast().ToList(), }; await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); // ArrayLength override drops the parsed `,10` suffix → libplctag tag name is plain // "N7:0" + ElementCount=20. factory.Tags.ShouldContainKey("N7:0"); factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(20); } [Fact] public async Task ScalarTag_passes_ElementCount_one() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition( "Scalar", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 7 }; await drv.ReadAsync(["Scalar"], TestContext.Current.CancellationToken); factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1); } // ---- Driver discovery emits IsArray + ArrayDim ---- [Fact] public async Task DiscoverAsync_emits_IsArray_and_ArrayDim_for_array_tag() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)], Tags = [ new AbLegacyTagDefinition("Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int), new AbLegacyTagDefinition("Scalar", "ab://10.0.0.5/1,0", "N7:5", AbLegacyDataType.Int), new AbLegacyTagDefinition("OverrideBlock", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, ArrayLength: 20), ], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var builder = new RecordingBuilder(); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); var block = builder.Variables.Single(v => v.BrowseName == "Block"); block.Info.IsArray.ShouldBeTrue(); block.Info.ArrayDim.ShouldBe((uint)10); block.Info.DriverDataType.ShouldBe(DriverDataType.Int32); var scalar = builder.Variables.Single(v => v.BrowseName == "Scalar"); scalar.Info.IsArray.ShouldBeFalse(); scalar.Info.ArrayDim.ShouldBeNull(); var overrideBlock = builder.Variables.Single(v => v.BrowseName == "OverrideBlock"); overrideBlock.Info.IsArray.ShouldBeTrue(); overrideBlock.Info.ArrayDim.ShouldBe((uint)20); overrideBlock.Info.DriverDataType.ShouldBe(DriverDataType.Float32); } // ---- Driver array reads ---- [Fact] public async Task ReadAsync_array_N_file_returns_int_array() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition( "Block", "ab://10.0.0.5/1,0", "N7:0,10", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValues = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], }; var snapshots = await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var arr = snapshots.Single().Value.ShouldBeOfType(); arr.ShouldBe([10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); } [Fact] public async Task ReadAsync_array_F_file_returns_float_array() { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition( "Block", "ab://10.0.0.5/1,0", "F8:0,4", AbLegacyDataType.Float)], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValues = [1.5f, 2.5f, 3.5f, 4.5f], }; var snapshots = await drv.ReadAsync(["Block"], TestContext.Current.CancellationToken); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var arr = snapshots.Single().Value.ShouldBeOfType(); arr.ShouldBe([1.5f, 2.5f, 3.5f, 4.5f]); } [Fact] public async Task ReadAsync_array_B_file_returns_bool_array_one_bool_per_word() { // Rockwell convention: B3:0,10 reads 10 BOOL words, NOT 160 individual bits. Each // word's bit 0 (or, here, "any bit set") expands into one Boolean entry. var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition( "Bits", "ab://10.0.0.5/1,0", "B3:0,4", AbLegacyDataType.Bit)], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValues = [true, false, true, false], }; var snapshots = await drv.ReadAsync(["Bits"], TestContext.Current.CancellationToken); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var arr = snapshots.Single().Value.ShouldBeOfType(); arr.ShouldBe([true, false, true, false]); } [Fact] public async Task ReadAsync_scalar_path_unchanged() { // Regression: a non-array tag still goes through the scalar decoder + returns a single // value, not a one-element array. var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition( "Scalar", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], }, "drv-1", factory); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 }; var snapshots = await drv.ReadAsync(["Scalar"], TestContext.Current.CancellationToken); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); snapshots.Single().Value.ShouldBe(42); } // ---- Recording builder for DiscoverAsync ---- private sealed class RecordingBuilder : IAddressSpaceBuilder { public List Variables { get; } = []; public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Variables.Add(new RecordedVariable(browseName, attributeInfo)); return new RecordingHandle(); } public void AddProperty(string browseName, DriverDataType dataType, object? value) { } private sealed class RecordingHandle : IVariableHandle { public string FullReference => ""; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException(); } } private sealed record RecordedVariable(string BrowseName, DriverAttributeInfo Info); }