397 lines
16 KiB
C#
397 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// PR 7 — array contiguous block addressing. One libplctag tag with <c>elem_count=N</c>
|
|
/// pulls N consecutive PCCC words in a single frame. Parser accepts both Rockwell `,N`
|
|
/// and libplctag `[N]` suffixes; driver advertises <see cref="DriverAttributeInfo.IsArray"/>
|
|
/// and decodes the buffer element-by-element.
|
|
/// </summary>
|
|
[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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<object?>().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<int[]>();
|
|
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<float[]>();
|
|
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<bool[]>();
|
|
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<RecordedVariable> 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);
|
|
}
|