using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Pins 's AbLegacy row against the REAL write path, the same way the Modbus, /// S7 and Galaxy parity tests do: the server must advertise an array node writable exactly when /// encodes a whole-array value for it, so a driver change that adds or drops /// an array shape fails here until the table follows. /// /// /// The driver is driven end to end — FromTagConfig, the transport's own WriteRefusal, the data-file /// width check, the element conversion — against a fake runtime that records EncodeArray. The value written /// is the CLR array the server hands the driver for the node's DataType (the node manager converts a numeric /// client array to the node's element type first), sized to the node's declared length. /// [Trait("Category", "Unit")] public sealed class AbLegacyArrayWriteSupportParityTests { private const string Host = "ab://10.0.0.5/1,0"; // Tag.DataType name → the CLR array the server delivers for a 1-D write of that type. private static Array ClrArray(string dataType, int n) => dataType switch { "Boolean" => Enumerable.Repeat(true, n).ToArray(), "SByte" => Enumerable.Repeat((sbyte)1, n).ToArray(), "Byte" => Enumerable.Repeat((byte)1, n).ToArray(), "Int16" => Enumerable.Repeat((short)1, n).ToArray(), "UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(), "Int32" => Enumerable.Repeat(1, n).ToArray(), "UInt32" => Enumerable.Repeat(1u, n).ToArray(), "Int64" => Enumerable.Repeat(1L, n).ToArray(), "UInt64" => Enumerable.Repeat(1UL, n).ToArray(), "Float" => Enumerable.Repeat(1f, n).ToArray(), "Double" => Enumerable.Repeat(1d, n).ToArray(), "String" => Enumerable.Repeat("a", n).ToArray(), "DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(), _ => throw new ArgumentOutOfRangeException(nameof(dataType)), }; private static readonly string[] NodeTypes = ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float", "Double", "String", "DateTime"]; private static string AddressFor(AbLegacyDataType t) => t switch { AbLegacyDataType.Float => "F8:0", AbLegacyDataType.Long => "L9:0", AbLegacyDataType.Bit => "B3:0", AbLegacyDataType.String => "ST9:0", AbLegacyDataType.TimerElement => "T4:0", AbLegacyDataType.CounterElement => "C5:0", AbLegacyDataType.ControlElement => "R6:0", _ => "N7:0", }; private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) { public Array? Encoded { get; private set; } public override void EncodeArray(AbLegacyDataType type, Array values) => Encoded = values; } // The fake runtime, but the PRODUCTION transport's pre-wire write refusal (libplctag: translated names; native: // PLC-5 and status-bit sub-elements). private sealed class ParityFactory(IAbLegacyTagFactory transport) : IAbLegacyTagFactory { public List Created { get; } = []; public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) { var tag = new RecordingTag(p); lock (Created) Created.Add(tag); return tag; } public string? WriteRefusal(AbLegacyTagCreateParams p) => transport.WriteRefusal(p); } // Whether the driver encodes a whole-array write of the node's declared length for this tag. private static async Task DriverEncodesAsync( string tagConfig, string nodeType, int nodeLength, AbLegacyPlcFamily family, bool native) { var factory = new ParityFactory(native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory()); using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host, family)], RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-parity", factory); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, nodeLength))], CancellationToken.None); bool encoded; lock (factory.Created) encoded = factory.Created.Any(t => t.Encoded is { } a && a.Length == nodeLength && t.WriteCount == 1); return r[0].StatusCode == AbLegacyStatusMapper.Good && encoded; } public static TheoryData TypeMatrix() { var data = new TheoryData(); foreach (var t in Enum.GetValues()) foreach (var n in NodeTypes) data.Add(t, n); return data; } /// Every element type × every node DataType, on the matching data file: the table answers exactly what /// the write path does. [Theory] [MemberData(nameof(TypeMatrix))] public async Task Table_agrees_with_the_write_path_for_every_element_and_node_type(AbLegacyDataType dataType, string nodeType) { var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}"""; var driver = await DriverEncodesAsync(json, nodeType, 2, AbLegacyPlcFamily.Slc500, native: false); DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node"); } /// /// Shapes beyond the element type, both directions: the /bit, .sub, type-versus-file-width and /// over-long cases the write path refuses, a native PLC-5 device (refused before the wire), and their positive /// controls. Each row carries the address, element type, length, family and transport. /// [Theory] [InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)] [InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Slc500, true)] [InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.MicroLogix, true)] [InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, false)] // libplctag writes PLC-5 [InlineData("N7:0", "Int", 4, AbLegacyPlcFamily.Plc5, true)] // native refuses every PLC-5 write [InlineData("B3:0", "Int", 4, AbLegacyPlcFamily.Slc500, false)] [InlineData("A9:0", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)] [InlineData("N7:10", "AnalogInt", 4, AbLegacyPlcFamily.Slc500, false)] [InlineData("L9:0", "Long", 4, AbLegacyPlcFamily.Slc500, false)] [InlineData("F8:0", "Float", 4, AbLegacyPlcFamily.Slc500, true)] [InlineData("N7:0", "Int", 256, AbLegacyPlcFamily.Slc500, false)] [InlineData("N7:0", "Int", 300, AbLegacyPlcFamily.Slc500, false)] // clamped to 256 by the factory: a 300-element write is refused [InlineData("N7:0/3", "Int", 2, AbLegacyPlcFamily.Slc500, false)] [InlineData("N7:0/3", "Bit", 2, AbLegacyPlcFamily.Slc500, false)] [InlineData("T4:0.ACC", "Int", 2, AbLegacyPlcFamily.Slc500, false)] [InlineData("F8:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)] [InlineData("N7:0", "Long", 2, AbLegacyPlcFamily.Slc500, false)] [InlineData("L9:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)] [InlineData("N7:0", "Float", 2, AbLegacyPlcFamily.Slc500, false)] [InlineData("S:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)] // libplctag refuses a translated name [InlineData("O:0", "Int", 2, AbLegacyPlcFamily.Slc500, false)] public async Task Table_agrees_with_the_write_path_for_each_shape( string address, string dataType, int length, AbLegacyPlcFamily family, bool native) { var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":{{length}}}"""; var driverConfig = native ? """{"transport":"Native"}""" : "{}"; var deviceConfig = $$"""{"hostAddress":"{{Host}}","plcFamily":"{{family}}"}"""; var driver = await DriverEncodesAsync(json, "Int16", length, family, native); DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json, driverConfig, deviceConfig) .ShouldBe(driver, $"{address} {dataType}[{length}] {family} native={native}"); } /// /// Shapes the table keeps read-only although the write path may encode them — the deliberately conservative /// direction, pinned so it is a decision and not drift: an INDIRECT address (its element moves with the index /// word, and the index read is not atomic with the write), and the file-number-less S: / I: / /// O: files on the native transport (libplctag refuses them; the table does not read the transport /// closely enough to widen output-image and status-file writes on native alone). /// [Theory] [InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] [InlineData("""{"address":"S:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")] [InlineData("""{"address":"O:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native"}""")] public void Table_keeps_the_conservative_shapes_read_only(string tagConfig, string driverConfig) => DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse(); /// /// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a /// /bit or .sub address, an indirect address, a type that does not match its data file (I3), and /// a native PLC-5 device. Keyed on dataType alone the row would advertise them writable. /// [Theory] [InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] [InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] [InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] [InlineData("""{"address":"F8:0","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] [InlineData("""{"address":"N7:0","dataType":"Long","isArray":true,"arrayLength":2}""", "{}")] [InlineData("""{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":2}""", """{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.5/1,0","plcFamily":"Plc5"}]}""")] public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) => DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse(); /// What the encoder accepts: Int, AnalogInt, Long, Float (on a matching file). [Theory] [InlineData(AbLegacyDataType.Int, true)] [InlineData(AbLegacyDataType.AnalogInt, true)] [InlineData(AbLegacyDataType.Long, true)] [InlineData(AbLegacyDataType.Float, true)] [InlineData(AbLegacyDataType.Bit, false)] [InlineData(AbLegacyDataType.String, false)] [InlineData(AbLegacyDataType.TimerElement, false)] public void Encoder_accepts_exactly_the_flat_numeric_element_types(AbLegacyDataType dataType, bool accepts) => (AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good) .ShouldBe(accepts); }