44ea6595e5
The Wave-2 lanes gave AbCip, AbLegacy and TwinCAT real whole-array write encoders (review 07 U10/U9), but the capability table still listed all three as scalar-only, so every array node materialised read-only and a client write was BadNotWritable before it reached the driver. Rows added, each listing only what WriteAsync encodes end to end: - AbCip: SInt/USInt/Int/UInt/DInt/UDInt/Real/Dt/LInt/ULInt/LReal, declared length, not a safetyTag. BOOL/STRING/Structure arrays stay read-only. - AbLegacy: Int/AnalogInt on N/B/A, Long on L, Float on F (file-width match), plain <file><n>:<word> address, <= 256 elements; not /bit, .sub, indirect, S:/I:/O:, nor native PLC-5. - TwinCAT: every codec element type, declared length; not a BOOL bit-in-word path. Elements convert in all three encoders, so the rows never key on an exact node DataType (ScadaBridge int[] -> Int16 stays writable); they leave out only node types whose elements never convert (DateTime/String over numeric, Boolean on AbLegacy, STRING under DateTime on TwinCAT: culture-dependent). Tests: driver-level parity for each driver (every element type x node type plus shape cases, against the real write path), the AbLegacy parity tests un-skipped, Core.Abstractions row tests, a composer test (three drivers writable, refused shapes read-only), and the existing AbCip negative cases moved to a BOOL array. Docs: Uns.md table, per-driver docs, data-plane rules. Refs #737 #738 #739 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
207 lines
12 KiB
C#
207 lines
12 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Pins <see cref="DriverArrayWriteSupport"/>'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
|
||
/// <see cref="AbLegacyDriver.WriteAsync"/> encodes a whole-array value for it, so a driver change that adds or drops
|
||
/// an array shape fails here until the table follows.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The driver is driven end to end — <c>FromTagConfig</c>, the transport's own <c>WriteRefusal</c>, the data-file
|
||
/// width check, the element conversion — against a fake runtime that records <c>EncodeArray</c>. The value written
|
||
/// is the CLR array the server hands the driver for the node's <c>DataType</c> (the node manager converts a numeric
|
||
/// client array to the node's element type first), sized to the node's declared length.
|
||
/// </remarks>
|
||
[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<RecordingTag> 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<bool> 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<AbLegacyDataType, string> TypeMatrix()
|
||
{
|
||
var data = new TheoryData<AbLegacyDataType, string>();
|
||
foreach (var t in Enum.GetValues<AbLegacyDataType>())
|
||
foreach (var n in NodeTypes)
|
||
data.Add(t, n);
|
||
return data;
|
||
}
|
||
|
||
/// <summary>Every element type × every node DataType, on the matching data file: the table answers exactly what
|
||
/// the write path does.</summary>
|
||
[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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shapes beyond the element type, both directions: the <c>/bit</c>, <c>.sub</c>, 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.
|
||
/// </summary>
|
||
[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}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <c>S:</c> / <c>I:</c> /
|
||
/// <c>O:</c> 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).
|
||
/// </summary>
|
||
[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();
|
||
|
||
/// <summary>
|
||
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
|
||
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
|
||
/// a native PLC-5 device. Keyed on <c>dataType</c> alone the row would advertise them writable.
|
||
/// </summary>
|
||
[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();
|
||
|
||
/// <summary>What the encoder accepts: Int, AnalogInt, Long, Float (on a matching file).</summary>
|
||
[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);
|
||
}
|