Merge master (75b7effb) into remed/lane-G1
Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+107
-1
@@ -138,11 +138,117 @@ public sealed class DriverArrayWriteSupportTests
|
||||
public void OpcUaClient_forwards_every_array() =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.OpcUaClient, "UInt16", "{\"nodeId\":\"ns=2;s=x\"}").ShouldBeTrue();
|
||||
|
||||
// ---- AbCip / AbLegacy / TwinCAT (review 07 U10 / U9: their array encoders landed in #737 / #738 / #739) ----
|
||||
|
||||
/// <summary>AbCip: the atomic element types <c>AbCipArrayCodec</c> encodes. BOOL (bit-packed DWORDs), STRING and
|
||||
/// structure arrays stay read-only, as do a safety tag, an undeclared length and a node type no element converts
|
||||
/// from.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"int\"" + Arr2 + "}", "Int16", true)] // case-insensitive, like the factory
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"LReal\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Dt\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInnt\"" + Arr2 + "}", "Int32", false)] // typo ⇒ the factory refuses the tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"safetyTag\":true" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"isArray\":true}", "Int32", false)] // no length ⇒ scalar
|
||||
[InlineData("{\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", false)] // no tagPath ⇒ not an AbCip tag
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "DateTime", false)] // DateTime elements never convert
|
||||
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "String", false)] // text converts only when numeric
|
||||
public void AbCip_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>AbLegacy: Int / AnalogInt / Long / Float on a plain address of the MATCHING data file. Every shape the
|
||||
/// write path refuses stays read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\"" + Arr2 + "}", "Int16", true)] // dataType ⇒ Int
|
||||
[InlineData("{\"address\":\" n7:10 \",\"dataType\":\"analogint\"" + Arr2 + "}", "Int16", true)] // trimmed, case-insensitive
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"A9:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"L9:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", true)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Float\"" + Arr2 + "}", "Float", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":256}", "Int16", true)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":257}", "Int16", false)] // the driver clamps to 256
|
||||
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Bit\"" + Arr2 + "}", "Boolean", false)]
|
||||
[InlineData("{\"address\":\"ST9:0\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"T4:0\",\"dataType\":\"TimerElement\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0/3\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"T4:0.ACC\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:[N7:0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // type / file mismatch
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"address\":\"S:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // libplctag refuses a translated name
|
||||
[InlineData("{\"address\":\"O:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Innt\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Boolean", false)] // bool elements are refused
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
public void AbLegacy_rows_follow_the_element_type_and_data_file(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>The native transport refuses every PLC-5 write before the wire, so a native PLC-5 device keeps its
|
||||
/// arrays read-only, wherever the transport and the family are configured. libplctag PLC-5 is the control.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"transport\":\"Native\",\"devices\":[{\"hostAddress\":\"ab://h/1,0\",\"plcFamily\":\"Plc5\"}]}", "{}", false)]
|
||||
[InlineData("{\"Transport\":\"native\"}", "{\"PlcFamily\":\"plc5\"}", false)]
|
||||
[InlineData("{\"transport\":\"Native\"}", "{\"plcFamily\":\"Slc500\"}", true)]
|
||||
[InlineData("{\"transport\":\"Libplctag\"}", "{\"plcFamily\":\"Plc5\"}", true)]
|
||||
[InlineData("{}", "{\"plcFamily\":\"Plc5\"}", true)] // absent ⇒ libplctag
|
||||
public void AbLegacy_native_PLC5_stays_read_only(string driverConfig, string deviceConfig, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16",
|
||||
"{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", driverConfig, deviceConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>TwinCAT: every element type <c>TwinCATArrayCodec</c> encodes (the factory refuses Structure tags). A
|
||||
/// BOOL bit-in-word path writes a scalar bit and is refused for an array value.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "String", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "Double", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"DateTime\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Grid[0,0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)] // bit-in-word
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
||||
[InlineData("{\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "DateTime", false)] // a duration, not a date
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "DateTime", true)]
|
||||
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "DateTime", false)] // culture-dependent text vs strict Windows-1252
|
||||
public void TwinCAT_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, dataType, tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>ScadaBridge writes CLR <c>int[]</c> / <c>List<int></c> to Int16 nodes, and an unknown DataType
|
||||
/// name (served as BaseDataType) hands the driver whatever the client sent. The element CONVERTS in all three
|
||||
/// encoders, so neither case may make the row read-only.</summary>
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.AbCip, "{\"tagPath\":\"Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.AbLegacy, "{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
[InlineData(DriverTypeNames.TwinCAT, "{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
||||
public void Converting_drivers_do_not_key_on_an_exact_node_type(string driverType, string tagConfig)
|
||||
{
|
||||
foreach (var dataType in new[] { "Int16", "Int32", "Int64", "UInt16", "Double", "Float32", "Reference" })
|
||||
DriverArrayWriteSupport.Supports(driverType, dataType, tagConfig).ShouldBeTrue($"{driverType} {dataType}");
|
||||
DriverArrayWriteSupport.Supports(driverType, null, tagConfig).ShouldBeTrue($"{driverType} <null>");
|
||||
}
|
||||
|
||||
/// <summary>Every driver type NOT in the table — and an unknown or absent one — stays read-only.</summary>
|
||||
[Fact]
|
||||
public void Every_other_driver_type_is_not_array_writable()
|
||||
{
|
||||
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
|
||||
var listed = new[]
|
||||
{
|
||||
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy,
|
||||
DriverTypeNames.AbCip, DriverTypeNames.AbLegacy, DriverTypeNames.TwinCAT,
|
||||
};
|
||||
var others = DriverTypeNames.All.Except(listed).ToList();
|
||||
others.ShouldNotBeEmpty();
|
||||
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbCip row against the REAL write path (#737, review 07 U10), the
|
||||
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
|
||||
/// <see cref="AbCipDriver.WriteAsync"/> encodes a whole-array value for it (through the production
|
||||
/// <see cref="AbCipArrayCodec"/>, which the fake runtime's <c>EncodeArray</c> calls), so a driver change that adds
|
||||
/// or drops an array element type fails here until the table follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 (ScadaBridge's <c>int[]</c>) to the node's element type first, and the codec
|
||||
/// then converts each element to the controller's type. The fake controller holds exactly
|
||||
/// <c>width × count</c> bytes, so only the element type and the value can make the write fail.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayWriteSupportParityTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
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(),
|
||||
// Non-numeric text: a String node over a numeric element converts only when the text happens to be a
|
||||
// number, which is not a write the server can promise — the table keeps it read-only.
|
||||
"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 async Task<bool> DriverEncodesAsync(string tagConfig, AbCipDataType elementType, string nodeType, int count)
|
||||
{
|
||||
// The controller holds exactly what an encodable element array of this length occupies.
|
||||
var size = (AbCipArrayCodec.ElementWidth(elementType) ?? 1) * count;
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
|
||||
using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Device)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-parity", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
|
||||
|
||||
return r[0].StatusCode == AbCipStatusMapper.Good
|
||||
&& factory.Tags.TryGetValue("Arr", out var tag)
|
||||
&& tag.EncodedArrayCount == count && tag.WriteCount == 1;
|
||||
}
|
||||
|
||||
public static TheoryData<AbCipDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<AbCipDataType, string>();
|
||||
foreach (var t in Enum.GetValues<AbCipDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType: 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(AbCipDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"tagPath":"Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
|
||||
|
||||
var driver = await DriverEncodesAsync(json, dataType, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: a safety tag (never writable), an undeclared length (a
|
||||
/// scalar to the factory, whose scalar encoder cannot take an array), a 1-element array, an element-offset path,
|
||||
/// and their positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":3}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":1}""", 1)]
|
||||
[InlineData("""{"tagPath":"Arr","isArray":true,"arrayLength":3}""", 3)] // dataType ⇒ DInt
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","safetyTag":true,"isArray":true,"arrayLength":3}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":true,"arrayLength":0}""", 3)]
|
||||
[InlineData("""{"tagPath":"Arr","dataType":"DInt","isArray":false,"arrayLength":3}""", 3)]
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, int nodeLength)
|
||||
{
|
||||
var driver = await DriverEncodesAsync(tagConfig, AbCipDataType.DInt, "Int32", nodeLength);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int32", tagConfig).ShouldBe(driver, tagConfig);
|
||||
}
|
||||
|
||||
/// <summary>The ScadaBridge wire shape reaches the codec: a CLR <c>int[]</c> (and <c>List<int></c>) to an
|
||||
/// INT array on an Int16 node the table advertises writable encodes. Positive control for the conversion rule.</summary>
|
||||
[Fact]
|
||||
public async Task A_ScadaBridge_int_list_to_an_INT_array_encodes()
|
||||
{
|
||||
const string json = """{"tagPath":"Arr","dataType":"Int","isArray":true,"arrayLength":3}""";
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, "Int16", json).ShouldBeTrue();
|
||||
|
||||
foreach (object value in new object[] { new[] { 1, -2, 300 }, new List<int> { 1, -2, 300 } })
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 6 } };
|
||||
using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = [new RawTagEntry("Arr", json, WriteIdempotent: false, DeviceName: Device)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-parity-sb", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", value)], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Arr"].EncodedArrayBytes.ShouldBe(Convert.FromHexString("0100FEFF2C01"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-18
@@ -1,24 +1,48 @@
|
||||
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 array encoder
|
||||
/// (<see cref="AbLegacyArrayWriteEncoder"/>), the same way the Modbus and S7 parity tests do: for every element
|
||||
/// type the server must advertise an array node writable exactly when the driver encodes it.
|
||||
/// 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>
|
||||
/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
|
||||
/// <see cref="DriverArrayWriteSupport"/> (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
|
||||
/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
|
||||
/// adds the row, delete the Skip; this test then holds the table and the encoder together.
|
||||
/// 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
|
||||
{
|
||||
public static TheoryData<AbLegacyDataType> AllTypes() => new(Enum.GetValues<AbLegacyDataType>());
|
||||
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
|
||||
{
|
||||
@@ -32,24 +56,131 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
_ => "N7:0",
|
||||
};
|
||||
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[MemberData(nameof(AllTypes))]
|
||||
public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
|
||||
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 encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
|
||||
&& AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
|
||||
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. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
|
||||
/// advertise them writable. Skipped with the parity theory until F1 lands.
|
||||
/// a native PLC-5 device. Keyed on <c>dataType</c> alone the row would advertise them writable.
|
||||
/// </summary>
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[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}""", "{}")]
|
||||
@@ -60,7 +191,7 @@ public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
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 today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
|
||||
/// <summary>What the encoder accepts: Int, AnalogInt, Long, Float (on a matching file).</summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.Int, true)]
|
||||
[InlineData(AbLegacyDataType.AnalogInt, true)]
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT with
|
||||
/// a CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for MQTTnet to report
|
||||
/// a CONNACK, answer PINGREQ with PINGRESP, SUBSCRIBE with a SUBACK (unless told not to), and record
|
||||
/// PUBLISHes (PUBACKing QoS 1 only when told to). It never forwards a PUBLISH to a subscriber. That is enough for MQTTnet to report
|
||||
/// a real connection — which is what makes a real drop (<see cref="DropAll"/>) drive a real
|
||||
/// <c>DisconnectedAsync</c> and a real reconnect, rather than a test seam standing in for one.
|
||||
/// </summary>
|
||||
@@ -59,6 +60,144 @@ internal sealed class MiniBroker : IDisposable
|
||||
/// </summary>
|
||||
public byte ConnAckReturnCode { get; set; } = ReturnCodeAccepted;
|
||||
|
||||
/// <summary>
|
||||
/// When true (the default) a SUBSCRIBE is answered with a SUBACK granting each filter's requested
|
||||
/// QoS. False is a broker that accepts SUBSCRIBE and never answers — the subscribe-deadline shape.
|
||||
/// </summary>
|
||||
public bool AnswerSubscribe { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the SUBACK for one SUBSCRIBE packet read whole into <paramref name="buffer"/>, or null when
|
||||
/// the packet is not parseable from one read (the fake handles one packet per read, like CONNECT).
|
||||
/// </summary>
|
||||
private static byte[]? TryBuildSubAck(byte[] buffer, int read)
|
||||
{
|
||||
// Fixed header: type byte, then the variable-length "remaining length".
|
||||
var at = 1;
|
||||
var remaining = 0;
|
||||
var multiplier = 1;
|
||||
byte digit;
|
||||
do
|
||||
{
|
||||
if (at >= read) return null;
|
||||
digit = buffer[at++];
|
||||
remaining += (digit & 0x7F) * multiplier;
|
||||
multiplier *= 128;
|
||||
}
|
||||
while ((digit & 0x80) != 0);
|
||||
|
||||
var end = at + remaining;
|
||||
if (end > read || remaining < 2) return null;
|
||||
|
||||
var packetIdHigh = buffer[at];
|
||||
var packetIdLow = buffer[at + 1];
|
||||
at += 2;
|
||||
|
||||
var grants = new List<byte>();
|
||||
while (at + 2 <= end)
|
||||
{
|
||||
var length = (buffer[at] << 8) | buffer[at + 1];
|
||||
at += 2 + length;
|
||||
if (at >= end) return null;
|
||||
grants.Add((byte)(buffer[at] & 0x03));
|
||||
at++;
|
||||
}
|
||||
|
||||
var ack = new List<byte> { 0x90, (byte)(2 + grants.Count), packetIdHigh, packetIdLow };
|
||||
ack.AddRange(grants);
|
||||
return [.. ack];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, a QoS 1 PUBLISH is answered with a PUBACK. False (the default) is a broker that takes
|
||||
/// the PUBLISH and never acknowledges it — the stalled-broker shape a write deadline exists for.
|
||||
/// </summary>
|
||||
public bool AckPublish { get; set; }
|
||||
|
||||
/// <summary>Every PUBLISH received, in arrival order, as <c>(topic, payload)</c>.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentQueue<(string Topic, byte[] Payload)> Published { get; } = new();
|
||||
|
||||
/// <summary>Handles one complete packet; returns false when the connection must close.</summary>
|
||||
private async Task<bool> HandlePacketAsync(NetworkStream stream, byte[] packet)
|
||||
{
|
||||
var packetType = packet[0] & 0xF0;
|
||||
if (packetType == 0x10)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
|
||||
|
||||
// A real broker closes the socket straight after refusing, which is what drives
|
||||
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
|
||||
return ConnAckReturnCode == ReturnCodeAccepted;
|
||||
}
|
||||
|
||||
if (packetType == 0xC0)
|
||||
{
|
||||
await stream.WriteAsync(PingResp, _cts.Token);
|
||||
}
|
||||
else if (packetType == 0x80 && AnswerSubscribe && TryBuildSubAck(packet, packet.Length) is { } subAck)
|
||||
{
|
||||
await stream.WriteAsync(subAck, _cts.Token);
|
||||
}
|
||||
else if (packetType == 0x30)
|
||||
{
|
||||
var qos = (packet[0] >> 1) & 0x03;
|
||||
var at = HeaderLength(packet);
|
||||
var topicLength = (packet[at] << 8) | packet[at + 1];
|
||||
var topic = System.Text.Encoding.UTF8.GetString(packet, at + 2, topicLength);
|
||||
at += 2 + topicLength;
|
||||
byte idHigh = 0, idLow = 0;
|
||||
if (qos > 0)
|
||||
{
|
||||
idHigh = packet[at];
|
||||
idLow = packet[at + 1];
|
||||
at += 2;
|
||||
}
|
||||
|
||||
Published.Enqueue((topic, packet[at..]));
|
||||
if (qos == 1 && AckPublish)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x40, 0x02, idHigh, idLow }, _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Length of a packet's fixed header (type byte + variable-length remaining length).</summary>
|
||||
private static int HeaderLength(byte[] packet)
|
||||
{
|
||||
var at = 1;
|
||||
while ((packet[at] & 0x80) != 0) at++;
|
||||
return at + 1;
|
||||
}
|
||||
|
||||
/// <summary>Removes one complete packet from the front of <paramref name="pending"/>, if one is there.</summary>
|
||||
private static bool TryTakePacket(List<byte> pending, out byte[] packet)
|
||||
{
|
||||
packet = [];
|
||||
if (pending.Count < 2) return false;
|
||||
|
||||
var at = 1;
|
||||
var remaining = 0;
|
||||
var multiplier = 1;
|
||||
byte digit;
|
||||
do
|
||||
{
|
||||
if (at >= pending.Count) return false;
|
||||
digit = pending[at++];
|
||||
remaining += (digit & 0x7F) * multiplier;
|
||||
multiplier *= 128;
|
||||
}
|
||||
while ((digit & 0x80) != 0);
|
||||
|
||||
var total = at + remaining;
|
||||
if (pending.Count < total) return false;
|
||||
|
||||
packet = pending.GetRange(0, total).ToArray();
|
||||
pending.RemoveRange(0, total);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Total TCP connections accepted since construction — counts reconnect attempts.</summary>
|
||||
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
|
||||
|
||||
@@ -130,7 +269,8 @@ internal sealed class MiniBroker : IDisposable
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
var buffer = new byte[1024];
|
||||
var buffer = new byte[4096];
|
||||
var pending = new List<byte>();
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
@@ -147,21 +287,22 @@ internal sealed class MiniBroker : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
var packetType = buffer[0] & 0xF0;
|
||||
if (packetType == 0x10)
|
||||
// Frame properly: several packets can share one TCP read (concurrent publishes do), and
|
||||
// one packet can span two.
|
||||
pending.AddRange(buffer.AsSpan(0, read).ToArray());
|
||||
var close = false;
|
||||
while (TryTakePacket(pending, out var packet))
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
|
||||
|
||||
// A real broker closes the socket straight after refusing, which is what drives
|
||||
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
|
||||
if (ConnAckReturnCode != ReturnCodeAccepted)
|
||||
if (!await HandlePacketAsync(stream, packet))
|
||||
{
|
||||
close = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (packetType == 0xC0)
|
||||
|
||||
if (close)
|
||||
{
|
||||
await stream.WriteAsync(PingResp, _cts.Token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-4.</b> <c>sparkplug.birthObservationWindowSeconds</c> was authored, range-checked and
|
||||
/// persisted by the AdminUI and consumed by nothing. It now bounds how long the Sparkplug browse surface
|
||||
/// collects NBIRTH/DBIRTH traffic before calling the observed set stable: the open budget grows by it,
|
||||
/// the open lingers while births are still arriving, and an operator rebirth request waits (bounded) for
|
||||
/// the births it asked for, so the tree the picker re-renders already carries them.
|
||||
/// </summary>
|
||||
public sealed class MqttBirthObservationWindowTests
|
||||
{
|
||||
private static MqttDriverOptions Sparkplug(int windowSeconds, int connectTimeoutSeconds = 10) => new()
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
ConnectTimeoutSeconds = connectTimeoutSeconds,
|
||||
Sparkplug = new MqttSparkplugOptions { GroupId = "Plant1", BirthObservationWindowSeconds = windowSeconds },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OpenBudget_Sparkplug_AddsTheBirthObservationWindow()
|
||||
{
|
||||
MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 15)).ShouldBe(TimeSpan.FromSeconds(10 + 15));
|
||||
MqttDriverBrowser.OpenBudget(Sparkplug(windowSeconds: 40)).ShouldBe(TimeSpan.FromSeconds(10 + 40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenBudget_Plain_IgnoresTheSparkplugWindow()
|
||||
{
|
||||
var plain = Sparkplug(windowSeconds: 40) with { Mode = MqttMode.Plain };
|
||||
MqttDriverBrowser.OpenBudget(plain).ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-5, 1)]
|
||||
[InlineData(15, 15)]
|
||||
[InlineData(100_000, 120)]
|
||||
public void BirthWindow_IsClamped(int configured, int expectedSeconds)
|
||||
=> MqttDriverBrowser.BirthWindow(Sparkplug(configured)).ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 4: the rebirth wait is capped below the AdminUI's 20 s per-call timeout, so a long
|
||||
/// configured window cannot hold the button past it (and a short one still governs).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(120, 15)]
|
||||
[InlineData(15, 15)]
|
||||
[InlineData(5, 5)]
|
||||
public async Task RebirthWait_IsTheWindowCappedAt15Seconds(int windowSeconds, int expectedSeconds)
|
||||
{
|
||||
await using var s = new MqttBrowseSession(
|
||||
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromSeconds(windowSeconds));
|
||||
s.RebirthWait.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
}
|
||||
|
||||
/// <summary>An idle plant costs one quiet interval, not the whole window.</summary>
|
||||
[Fact]
|
||||
public async Task Settle_WithNoBirths_ReturnsAfterOneQuietInterval()
|
||||
{
|
||||
var reads = 0;
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() => { reads++; return 0; },
|
||||
baseline: 0,
|
||||
awaitFirstBirth: false,
|
||||
quiet: TimeSpan.FromMilliseconds(20),
|
||||
window: TimeSpan.FromMinutes(5),
|
||||
TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
reads.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// While births keep arriving the settle keeps collecting; it returns on the first quiet interval
|
||||
/// after the last one. Counted in reads, not wall-clock, so it cannot flake.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Settle_KeepsCollectingWhileBirthsArrive_ThenReturnsOnQuiet()
|
||||
{
|
||||
var reads = 0;
|
||||
long births = 0;
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() =>
|
||||
{
|
||||
reads++;
|
||||
if (reads <= 5) births++; // five intervals with fresh births, then silence
|
||||
return births;
|
||||
},
|
||||
baseline: 0,
|
||||
awaitFirstBirth: false,
|
||||
quiet: TimeSpan.FromMilliseconds(20),
|
||||
window: TimeSpan.FromMinutes(5),
|
||||
TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
reads.ShouldBe(6);
|
||||
}
|
||||
|
||||
/// <summary>The window is a hard ceiling: a birth flood that never goes quiet still ends.</summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task Settle_NeverQuiet_EndsAtTheWindow()
|
||||
{
|
||||
long births = 0;
|
||||
await MqttBrowseSession.SettleBirthsAsync(
|
||||
() => ++births,
|
||||
baseline: 0,
|
||||
awaitFirstBirth: false,
|
||||
quiet: TimeSpan.FromMilliseconds(20),
|
||||
window: TimeSpan.FromSeconds(1),
|
||||
TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
births.ShouldBeGreaterThan(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rebirth request waits for the birth it asked for. The fixed version cannot complete
|
||||
/// synchronously (it awaits the settle), the unfixed one completes before the call returns — so the
|
||||
/// <c>IsCompleted</c> check is deterministic, not a timing race. The birth then releases it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestRebirth_WaitsForTheRequestedBirths_ThenReturns()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(
|
||||
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5));
|
||||
|
||||
var request = s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
|
||||
request.IsCompleted.ShouldBeFalse("the request must wait for the births it asked for");
|
||||
|
||||
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Uptime", SparkplugDataType.Int64);
|
||||
|
||||
(await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1);
|
||||
s.PublishCountForTest.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The wait is a courtesy, never a failure: the NCMD was already published, so a caller deadline
|
||||
/// during the settle returns the published count instead of reporting an error for a request that
|
||||
/// succeeded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestRebirth_CallerCancelsDuringTheSettle_StillReportsThePublish()
|
||||
{
|
||||
await using var s = new MqttBrowseSession(
|
||||
MqttMode.SparkplugB, client: null, logger: null, birthWindow: TimeSpan.FromMinutes(5));
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = s.RequestRebirthAsync("Plant1/EdgeA", cts.Token);
|
||||
request.IsCompleted.ShouldBeFalse();
|
||||
await cts.CancelAsync();
|
||||
|
||||
(await request.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -779,6 +779,13 @@ public sealed class MqttBrowseSessionTests
|
||||
"Plant1/EdgeA::Profile", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
|
||||
a.IsArray.ShouldBeTrue();
|
||||
|
||||
// R06:M-5: the ingestor refuses array metrics ("not supported in v1"), so the browser must not
|
||||
// offer one as bindable — no stated address means the browse-commit refuses it in words, instead
|
||||
// of committing a tag that sits at BadWaitingForInitialData forever. The scalar control is
|
||||
// SparkplugAttributes_StateTheBindingTuple_SoTheCommitNeverParsesTheNodeId.
|
||||
a.AddressFields.ShouldBeNull();
|
||||
a.SecurityClass.ShouldContain("not bindable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -796,6 +803,7 @@ public sealed class MqttBrowseSessionTests
|
||||
Enum.TryParse<Core.Abstractions.DriverDataType>(a.DriverDataType, ignoreCase: true, out _)
|
||||
.ShouldBeFalse(); // not a guessed type — unmappable, and visibly so
|
||||
a.SecurityClass.ShouldContain("unsupported");
|
||||
a.AddressFields.ShouldBeNull(); // R06:M-5 — labelled AND unbindable, not just labelled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+264
-4
@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
/// (<see cref="MqttDriver.ShutdownAsync"/> / <see cref="MqttDriver.DisposeAsync"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain mode deliberately: it is the only mode whose initialize needs no SUBSCRIBE, and
|
||||
/// <see cref="MiniBroker"/> answers CONNECT and PINGREQ and nothing else — so a Sparkplug driver
|
||||
/// would park on its group SUBSCRIBE until the deadline and prove nothing about connections.
|
||||
/// Plain mode deliberately: it is the only mode whose initialize needs no SUBSCRIBE.
|
||||
/// <see cref="MiniBroker"/> answers CONNECT, SUBSCRIBE (unless told not to) and PINGREQ, and never
|
||||
/// PUBLISHes, so ingest is driven through <c>Subscriptions.HandleMessage</c>.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverSessionLifecycleTests
|
||||
@@ -122,7 +122,7 @@ public sealed class MqttDriverSessionLifecycleTests
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ingest is fed through <c>Subscriptions.HandleMessage</c>, the method MQTTnet's dispatcher
|
||||
/// calls, because <see cref="MiniBroker"/> answers CONNECT and PINGREQ and nothing else: it
|
||||
/// calls, because <see cref="MiniBroker"/> answers CONNECT, SUBSCRIBE and PINGREQ only: it
|
||||
/// can drop a session (which is what this test needs from it) but cannot PUBLISH.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -174,6 +174,266 @@ public sealed class MqttDriverSessionLifecycleTests
|
||||
recovered.Value.ShouldBe(21.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-1, end to end.</b> A SUBSCRIBE the broker never answers used to come back from
|
||||
/// <see cref="MqttDriver.SubscribeAsync"/> as a live handle while the topic left every retry set, so
|
||||
/// <c>DriverInstanceActor</c> never re-subscribed and the tag stayed dark behind a Healthy connection.
|
||||
/// It must throw (the actor's reconcile then retries), and the retry against a broker that answers
|
||||
/// must establish and deliver — the positive control.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ASubscribeTheBrokerNeverAnswers_Throws_AndTheRetryEstablishes()
|
||||
{
|
||||
const string rawPath = "Plant/Mqtt/dev1/Temp";
|
||||
const string topic = "factory/oven/temp";
|
||||
|
||||
using var broker = new MiniBroker { AnswerSubscribe = false };
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry(
|
||||
rawPath,
|
||||
$$"""{"topic":"{{topic}}","payloadFormat":"Scalar","dataType":"Float64"}""",
|
||||
WriteIdempotent: false),
|
||||
],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
driver.SubscribeAsync([rawPath], TimeSpan.Zero, TestContext.Current.CancellationToken));
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "the connection itself is fine — that was the trap");
|
||||
|
||||
broker.AnswerSubscribe = true;
|
||||
var handle = await driver.SubscribeAsync([rawPath], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, e) => { lock (raised) raised.Add(e); };
|
||||
driver.Subscriptions.HandleMessage(topic, "7"u8.ToArray(), retained: false);
|
||||
lock (raised)
|
||||
{
|
||||
raised.ShouldHaveSingleItem().SubscriptionHandle.ShouldBe(handle);
|
||||
raised[0].Snapshot.StatusCode.ShouldBe(0u);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-3.</b> A refused initial connect used to rethrow without disposing the
|
||||
/// <see cref="MqttConnection"/> (and its <c>IMqttClient</c>) it had just built. Control: a
|
||||
/// successful initialize leaves its connection live, so the seam is not reporting "disposed" for
|
||||
/// everything.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_RefusedConnect_DisposesTheConnectionItBuilt()
|
||||
{
|
||||
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
var built = new List<MqttConnection>();
|
||||
driver.ConnectionCreatedForTests = c => built.Add(c);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
|
||||
built.ShouldHaveSingleItem().State.ShouldBe(MqttConnectionState.Disposed);
|
||||
|
||||
broker.ConnAckReturnCode = MiniBroker.ReturnCodeAccepted;
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
built.Count.ShouldBe(2);
|
||||
built[1].State.ShouldBe(MqttConnectionState.Connected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-10 + the lifecycle-generation rule.</b> A shutdown that times out on the lifecycle gate
|
||||
/// tears down ungated; the wedged initialize that held the gate must then NOT publish its freshly
|
||||
/// connected session behind that teardown, and its late health write must not overwrite the
|
||||
/// shutdown's. The positive controls: the wedged init really did reach the broker (AcceptedCount),
|
||||
/// and a later initialize connects and reports Healthy.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task AWedgedInit_CompletingAfterATimedOutShutdown_PublishesNothing()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port) with { ConnectTimeoutSeconds = 1 };
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.BeforeConnectHookForTests = async _ =>
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.Task;
|
||||
};
|
||||
|
||||
var initialize = Task.Run(() => driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
// Times out on the gate (1 s) and tears down ungated.
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
driver.BeforeConnectHookForTests = null;
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => initialize);
|
||||
|
||||
broker.AcceptedCount.ShouldBe(1, "the wedged initialize did connect — the control for the absence below");
|
||||
await WaitForAsync(() => broker.LiveConnections == 0, "the superseded session to be torn down");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded init must not overwrite newer health");
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Unknown);
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => broker.LiveConnections == 1, "the fresh session");
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private static RawTagEntry WritableTag(string rawPath, string writeTopic) => new(
|
||||
rawPath,
|
||||
$$"""{"topic":"{{writeTopic}}/state","payloadFormat":"Scalar","dataType":"Float64","writeTopic":"{{writeTopic}}"}""",
|
||||
WriteIdempotent: false);
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-8.</b> A write batch used to publish strictly serially, one awaited QoS 1 round-trip per
|
||||
/// request — against a stalled broker, N writes × the publish deadline. Different topics now publish
|
||||
/// concurrently: all four PUBLISHes reach a broker that never PUBACKs well inside ONE 30 s deadline
|
||||
/// (serial code would have sent exactly one). Cancelling the batch then fills every slot, so the
|
||||
/// batch shape holds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AWriteBatch_PublishesDifferentTopicsConcurrently()
|
||||
{
|
||||
using var broker = new MiniBroker(); // never PUBACKs
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
ConnectTimeoutSeconds = 30,
|
||||
RawTags = [.. Enumerable.Range(0, 4).Select(i => WritableTag($"Plant/Mqtt/dev1/Sp{i}", $"cmd/sp{i}"))],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var write = driver.WriteAsync(
|
||||
[.. Enumerable.Range(0, 4).Select(i => new WriteRequest($"Plant/Mqtt/dev1/Sp{i}", (double)i))],
|
||||
cts.Token);
|
||||
|
||||
await WaitForAsync(() => broker.Published.Count == 4, "all four PUBLISHes to be in flight at once");
|
||||
write.IsCompleted.ShouldBeFalse("no PUBACK has arrived, so nothing can have completed");
|
||||
|
||||
await cts.CancelAsync();
|
||||
var results = await write.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
results.Count.ShouldBe(4);
|
||||
results.ShouldAllBe(r => r.StatusCode == 0x80050000u); // BadCommunicationError, every slot filled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes to the SAME topic in one batch stay in request order — last write wins at the device —
|
||||
/// while the batch as a whole is concurrent. The broker PUBACKs here, so every slot is Good — and
|
||||
/// those acks advance <c>LastSuccessfulRead</c> (writes feed health).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AWriteBatch_KeepsSameTopicWritesInRequestOrder()
|
||||
{
|
||||
using var broker = new MiniBroker { AckPublish = true };
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
RawTags = [WritableTag("Plant/Mqtt/dev1/Sp", "cmd/sp"), WritableTag("Plant/Mqtt/dev1/Other", "cmd/other")],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBeNull("nothing has answered yet");
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[
|
||||
new WriteRequest("Plant/Mqtt/dev1/Sp", 1.0),
|
||||
new WriteRequest("Plant/Mqtt/dev1/Other", 9.0),
|
||||
new WriteRequest("Plant/Mqtt/dev1/Sp", 2.0),
|
||||
new WriteRequest("Plant/Mqtt/dev1/Sp", 3.0),
|
||||
],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
results.ShouldAllBe(r => r.StatusCode == 0u);
|
||||
broker.Published.Where(p => p.Topic == "cmd/sp")
|
||||
.Select(p => System.Text.Encoding.UTF8.GetString(p.Payload))
|
||||
.ShouldBe(["1", "2", "3"]);
|
||||
|
||||
// A PUBACK is the broker answering, so writes feed the evidence clock the comms-loss watchdog reads.
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 6: the bound itself. Ten topics against a broker that never PUBACKs: exactly
|
||||
/// <see cref="MqttDriver.MaxConcurrentPublishes"/> PUBLISHes go out and the count then holds — a
|
||||
/// regression to unbounded concurrency sends all ten. The stability check re-reads the count across
|
||||
/// several condition polls rather than sleeping once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AWriteBatch_NeverHasMoreThanTheBoundInFlight()
|
||||
{
|
||||
using var broker = new MiniBroker(); // never PUBACKs
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
ConnectTimeoutSeconds = 30,
|
||||
RawTags = [.. Enumerable.Range(0, 10).Select(i => WritableTag($"Plant/Mqtt/dev1/Sp{i}", $"cmd/sp{i}"))],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var write = driver.WriteAsync(
|
||||
[.. Enumerable.Range(0, 10).Select(i => new WriteRequest($"Plant/Mqtt/dev1/Sp{i}", (double)i))],
|
||||
cts.Token);
|
||||
|
||||
await WaitForAsync(() => broker.Published.Count >= MqttDriver.MaxConcurrentPublishes, "the bound to fill");
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
broker.Published.Count.ShouldBe(MqttDriver.MaxConcurrentPublishes, "a slot freed with no PUBACK received");
|
||||
}
|
||||
|
||||
await cts.CancelAsync();
|
||||
var results = await write.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
results.Count.ShouldBe(10);
|
||||
results.ShouldAllBe(r => r.StatusCode == 0x80050000u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 2: a QoS 0 publish (a Sparkplug command) completing only means the bytes reached the
|
||||
/// local socket buffer; the broker has not answered, so it must not advance <c>LastSuccessfulRead</c>.
|
||||
/// The positive control is <see cref="AWriteBatch_KeepsSameTopicWritesInRequestOrder"/>, where a QoS 1
|
||||
/// PUBACK does advance it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ASparkplugQos0Command_DoesNotAdvanceLastSuccessfulRead()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port) with
|
||||
{
|
||||
Mode = MqttMode.SparkplugB,
|
||||
Sparkplug = new MqttSparkplugOptions { GroupId = "G", RequestRebirthOnGap = false },
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry(
|
||||
"Plant/Mqtt/spb/Setpoint",
|
||||
"""{"groupId":"G","edgeNodeId":"E","metricName":"Setpoint","dataType":"Float64"}""",
|
||||
WriteIdempotent: false),
|
||||
],
|
||||
};
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
var publishedBefore = broker.Published.Count;
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest("Plant/Mqtt/spb/Setpoint", 4.5)], TestContext.Current.CancellationToken);
|
||||
|
||||
results.ShouldHaveSingleItem().StatusCode.ShouldBe(0u);
|
||||
await WaitForAsync(
|
||||
() => broker.Published.Skip(publishedBefore).Any(p => p.Topic == "spBv1.0/G/NCMD/E"),
|
||||
"the NCMD to reach the broker — the control that the write really went out");
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
|
||||
@@ -538,17 +538,224 @@ public sealed class MqttSubscriptionManagerTests
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A SUBSCRIBE that never got a verdict (transport throw / deadline) degrades its refs AND throws,
|
||||
/// so <c>DriverInstanceActor</c> holds no handle and its health-tick reconcile re-subscribes. It
|
||||
/// used to return a live-looking handle with the topic dropped from every retry set — dark behind a
|
||||
/// Healthy connection until a redeploy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_DoesNotThrow()
|
||||
public async Task SubscribeAsync_TransportThrows_DegradesRefsToBad_AndThrows()
|
||||
{
|
||||
var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
mgr.OnDataChange += (_, e) => raised.Add(e);
|
||||
|
||||
await Should.NotThrowAsync(() =>
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
// The Bad verdict still reached the subscriber before the throw.
|
||||
raised.ShouldContain(e => e.FullReference == OvenTempPath && e.Snapshot.StatusCode == BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 3: a cancel of the CALLER's token (a pipeline timeout, host shutdown) is a cancellation,
|
||||
/// not a broker fault — it must surface as OperationCanceledException, or the resilience breaker counts
|
||||
/// it as a failure. The refs are still Bad and pending, so the next reconnect retries them (the positive
|
||||
/// control). The uncancelled no-verdict case throws InvalidOperationException
|
||||
/// (<see cref="SubscribeAsync_TransportThrows_DegradesRefsToBad_AndThrows"/>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_CallerCancelled_ThrowsOperationCanceled_AndTheTopicStaysPending()
|
||||
{
|
||||
var transport = new FakeTransport { Throw = new OperationCanceledException() };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
var ex = await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), cts.Token));
|
||||
ex.ShouldNotBeOfType<InvalidOperationException>();
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
|
||||
transport.Throw = null;
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
transport.Calls.Last().Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reconcile path: once the actor re-subscribes after the throw, the topic IS re-SUBSCRIBEd and
|
||||
/// delivery resumes on the new handle — the positive control for the throw above.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_AfterATransportThrow_TheRetrySubscribesAndDelivers()
|
||||
{
|
||||
var transport = new FakeTransport { Throw = new InvalidOperationException("broker gone") };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
transport.Throw = null;
|
||||
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
mgr.OnDataChange += (_, e) => raised.Add(e);
|
||||
mgr.HandleMessage(OvenTopic, "21.5"u8.ToArray(), retained: false);
|
||||
|
||||
transport.Calls.Count.ShouldBe(2);
|
||||
transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
raised.ShouldHaveSingleItem().SubscriptionHandle.ShouldBe(handle);
|
||||
raised[0].Snapshot.Value.ShouldBe(21.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A topic whose first SUBSCRIBE was refused stays in a pending set the reconnect re-subscribe
|
||||
/// covers. The reconnect used to re-SUBSCRIBE only the granted set, so it was never retried.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_RetriesATopicWhoseFirstSubscribeWasRefused()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = _ => true };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
var handle = await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Reject = _ => false;
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(2);
|
||||
transport.Calls[1].Select(f => f.Topic).ShouldBe([OvenTopic]);
|
||||
|
||||
var raised = new List<DataChangeEventArgs>();
|
||||
mgr.OnDataChange += (_, e) => raised.Add(e);
|
||||
mgr.HandleMessage(OvenTopic, "3"u8.ToArray(), retained: false);
|
||||
raised.ShouldHaveSingleItem().SubscriptionHandle.ShouldBe(handle);
|
||||
raised[0].Snapshot.StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A persistently ACL-denied topic must not turn every reconnect into a teardown loop: a pending
|
||||
/// topic refused again is not "re-established nothing" — nothing that was live was lost. Both a
|
||||
/// mixed set (the granted topic still re-subscribes) and a pending-only set are covered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OnReconnectedAsync_PendingTopicsRefusedAgain_DoesNotThrow()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = t => t == "f/oven/press" };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.NotThrowAsync(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
transport.Calls[1].Select(f => f.Topic).OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ShouldBe(["f/oven/press", OvenTopic]);
|
||||
|
||||
var lone = new FakeTransport { Reject = _ => true };
|
||||
var mgr2 = NewManager(lone);
|
||||
mgr2.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr2.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.NotThrowAsync(() => mgr2.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
lone.Calls.Count.ShouldBe(2); // it WAS retried
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A later subscribe on the same session (a redeploy's bulk re-subscribe) also retries the pending
|
||||
/// set, so a broker ACL fixed while connected recovers without waiting for a reconnect.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_RetriesPendingTopicsFromAnEarlierRefusal()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = t => t == "f/oven/press" };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Reject = _ => false;
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls[1].Select(f => f.Topic).OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ShouldBe(["f/oven/press", OvenTopic]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-2.</b> Register prunes the cache to the authored set — an unauthored reference's
|
||||
/// BadNodeIdUnknown marker and a dropped tag's value go. Control: a still-authored tag keeps its
|
||||
/// value across the re-register (a redeploy must not blank observed values).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Register_PrunesCacheEntriesForReferencesNoLongerAuthored()
|
||||
{
|
||||
var mgr = NewManager(new FakeTransport());
|
||||
mgr.Register(
|
||||
[
|
||||
Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}"""),
|
||||
]);
|
||||
await mgr.SubscribeAsync([OvenTempPath, OvenPressPath, "Plant/Mqtt/broker1/Nope"], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
mgr.HandleMessage(OvenTopic, "1"u8.ToArray(), retained: false);
|
||||
mgr.HandleMessage("f/oven/press", "2"u8.ToArray(), retained: false);
|
||||
mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(BadNodeIdUnknown);
|
||||
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
|
||||
mgr.Values.Read(OvenTempPath).Value.ShouldBe(1.0);
|
||||
mgr.Values.Read(OvenPressPath).StatusCode.ShouldBe(0x80320000u);
|
||||
mgr.Values.Read("Plant/Mqtt/broker1/Nope").StatusCode.ShouldBe(0x80320000u);
|
||||
mgr.Values.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-11.</b> A subscribe issued before any transport is attached reaches no broker, so it must
|
||||
/// not be recorded as established. It used to be — "subscribed, receives nothing" — and the first
|
||||
/// reconnect then had nothing to re-establish. Now it stays pending, and the first reconnect on the
|
||||
/// attached session attempts it: the Bad verdict from the unconnected session is the proof.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_WithNoTransport_StaysPending_AndTheFirstReconnectAttemptsIt()
|
||||
{
|
||||
var mgr = NewManager(transport: null);
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
|
||||
|
||||
await using var attached = new MqttConnection(
|
||||
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1");
|
||||
mgr.AttachTo(attached);
|
||||
|
||||
// The unconnected session gives no verdict, so the reconnect throws — and it ATTEMPTED the topic.
|
||||
await Should.ThrowAsync<Exception>(() => mgr.OnReconnectedAsync(TestContext.Current.CancellationToken));
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register drops a pending topic whose tags the redeploy removed — the same housekeeping the
|
||||
/// granted set gets — so a deleted tag's denied topic is not retried forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Register_DroppingATag_RemovesItsTopicFromThePendingSet()
|
||||
{
|
||||
var transport = new FakeTransport { Reject = _ => true };
|
||||
var mgr = NewManager(transport);
|
||||
mgr.Register([Tag(OvenPressPath, """{"topic":"f/oven/press","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.SubscribeAsync([OvenPressPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""")]);
|
||||
await mgr.OnReconnectedAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
transport.Calls.Count.ShouldBe(1); // nothing left to retry
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
@@ -939,7 +1146,10 @@ public sealed class MqttSubscriptionManagerTests
|
||||
new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }, "mqtt-1");
|
||||
mgr.AttachTo(rebound);
|
||||
|
||||
await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
// The unconnected session gives no verdict, so the subscribe now THROWS (M-1) — the throw is a
|
||||
// second proof the SUBSCRIBE was attempted against the new session.
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(
|
||||
BadCommunicationError,
|
||||
|
||||
@@ -852,6 +852,28 @@ public sealed class SparkplugIngestorTests
|
||||
ing.TryResolve(TempPath, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-2.</b> Register prunes the shared cache to the authored set: an unauthored reference's
|
||||
/// BadNodeIdUnknown marker and a dropped tag's value go, so the cache cannot grow with every RawPath
|
||||
/// and stray reference ever seen. Control: a still-authored tag's value survives the re-register.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Register_PrunesCacheEntriesForReferencesNoLongerAuthored()
|
||||
{
|
||||
var ing = NewIngestor();
|
||||
await ing.SubscribeAsync(["Plant/Mqtt/spb/NotAThing"], TimeSpan.Zero, TestContext.Current.CancellationToken);
|
||||
var kept = new DataValueSnapshot(1.0f, 0u, null, DateTime.UtcNow);
|
||||
ing.Values.Update(TempPath, kept);
|
||||
ing.Values.Update(PressPath, kept);
|
||||
|
||||
ing.Register([Tag(TempPath, Blob(Group, Node, Device, "Temperature", "Float32"))]);
|
||||
|
||||
ing.Values.Read(TempPath).ShouldBe(kept);
|
||||
ing.Values.Read(PressPath).StatusCode.ShouldBe(0x80320000u); // dropped tag: no stale value
|
||||
ing.Values.Read("Plant/Mqtt/spb/NotAThing").StatusCode.ShouldBe(0x80320000u);
|
||||
ing.Values.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_UnauthoredReference_ReportsBadNodeIdUnknown()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using MQTTnet;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:M-6.</b> The wildcard trie replaced a linear <c>MqttTopicFilterComparer</c> scan on the
|
||||
/// dispatcher thread. Its contract is "a superset of the true matches, confirmed by the comparer", so it
|
||||
/// is pinned differentially against that comparer — the oracle a broker uses — over a seeded random
|
||||
/// space that deliberately includes empty levels, <c>$</c>-topics and wildcard-only filters.
|
||||
/// </summary>
|
||||
public sealed class TopicFilterTrieTests
|
||||
{
|
||||
private static readonly string[] Levels = ["a", "b", "c", "", "$SYS", "x y"];
|
||||
|
||||
private sealed class Collect : ITopicFilterVisitor<int>
|
||||
{
|
||||
public HashSet<int> Seen { get; } = [];
|
||||
|
||||
public void Visit(int value) => Seen.Add(value).ShouldBeTrue("a filter must be visited at most once per match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_IsASupersetOfTheComparer_AndConfirmingYieldsExactlyTheComparersAnswer()
|
||||
{
|
||||
var random = new Random(744);
|
||||
for (var round = 0; round < 200; round++)
|
||||
{
|
||||
var filters = Enumerable.Range(0, random.Next(1, 25)).Select(_ => RandomFilter(random)).ToList();
|
||||
var trie = TopicFilterTrie<int>.Build(filters.Select((f, i) => (f, i)));
|
||||
|
||||
for (var t = 0; t < 50; t++)
|
||||
{
|
||||
var topic = RandomTopic(random);
|
||||
var expected = filters
|
||||
.Select((f, i) => (f, i))
|
||||
.Where(p => MqttTopicFilterComparer.Compare(topic, p.f) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.Select(p => p.i)
|
||||
.ToHashSet();
|
||||
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
|
||||
collect.Seen.IsSupersetOf(expected).ShouldBeTrue($"topic '{topic}' missed a match; filters: {string.Join(" | ", filters)}");
|
||||
collect.Seen
|
||||
.Where(i => MqttTopicFilterComparer.Compare(topic, filters[i]) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.ToHashSet()
|
||||
.SetEquals(expected)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/#", "a", true)] // '#' includes the parent level
|
||||
[InlineData("a/#", "a/b/c", true)]
|
||||
[InlineData("a/+", "a/", true)] // '+' matches an empty level
|
||||
[InlineData("a/+", "a", false)]
|
||||
[InlineData("+/+", "a/b", true)]
|
||||
[InlineData("#", "anything/at/all", true)]
|
||||
[InlineData("a/b", "a/b", true)]
|
||||
[InlineData("a/b", "a/c", false)]
|
||||
public void Match_FindsTheCanonicalCases(string filter, string topic, bool expected)
|
||||
{
|
||||
var trie = TopicFilterTrie<int>.Build([(filter, 1)]);
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
collect.Seen.Contains(1).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review minor 7: partial-level wildcards and <c>$share/…</c> filters are outside the trie's level rules.
|
||||
/// Whatever it yields, confirmation by the broker's comparer must produce no match — pinned so a later
|
||||
/// "smarter" trie cannot start delivering them.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("a+", "ab")]
|
||||
[InlineData("a/b#", "a/bc")]
|
||||
[InlineData("a/b#", "a/b")]
|
||||
[InlineData("$share/g/a/b", "a/b")]
|
||||
[InlineData("$share/g/#", "a/b")]
|
||||
public void Match_partialLevelWildcardsAndSharedFilters_neverConfirm(string filter, string topic)
|
||||
{
|
||||
var trie = TopicFilterTrie<int>.Build([(filter, 1)]);
|
||||
var collect = new Collect();
|
||||
trie.Match(topic, ref collect);
|
||||
|
||||
collect.Seen.Where(_ => MqttTopicFilterComparer.Compare(topic, filter) == MqttTopicFilterCompareResult.IsMatch)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_MatchesNothing()
|
||||
{
|
||||
var collect = new Collect();
|
||||
TopicFilterTrie<int>.Empty.Match("a/b", ref collect);
|
||||
collect.Seen.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static string RandomFilter(Random random)
|
||||
{
|
||||
var count = random.Next(1, 5);
|
||||
var parts = new List<string>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var roll = random.Next(10);
|
||||
if (roll < 2 && i == count - 1) { parts.Add("#"); break; }
|
||||
parts.Add(roll < 5 ? "+" : Levels[random.Next(Levels.Length)]);
|
||||
}
|
||||
|
||||
return string.Join('/', parts);
|
||||
}
|
||||
|
||||
private static string RandomTopic(Random random) =>
|
||||
string.Join('/', Enumerable.Range(0, random.Next(1, 6)).Select(_ => Levels[random.Next(Levels.Length)]));
|
||||
}
|
||||
+4
@@ -38,6 +38,10 @@ public sealed class OracleFlavouredSqliteDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => _inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => _inner.RowLimitSuffix(rows);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => _inner.ListSchemasSql;
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ public sealed class SqlBrowseSessionTests
|
||||
attribute.Name.ShouldBe(SqliteBrowseFixture.RealColumn);
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
|
||||
attribute.IsArray.ShouldBeFalse();
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly");
|
||||
// R06:S-5: every dialect projects IS_NULLABLE and the picker now says so. The column is declared NULL.
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly · nullable");
|
||||
attribute.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -92,7 +93,10 @@ public sealed class SqlBrowseSessionTests
|
||||
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.TextColumn);
|
||||
var attributes = await session.AttributesAsync(nodeId, CancellationToken.None);
|
||||
|
||||
attributes.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
var attribute = attributes.ShouldHaveSingleItem();
|
||||
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.String));
|
||||
// The NOT NULL control for the nullable case above — the descriptor tracks the catalog, not a constant.
|
||||
attribute.SecurityClass.ShouldBe("ViewOnly · NOT NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -58,6 +58,127 @@ public sealed class SqlServerReadTests
|
||||
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
// ---- R06:S-1: the row cap, on real T-SQL ----
|
||||
|
||||
/// <summary>
|
||||
/// A where-pair selector pointed at an events table (40 rows for one station) used to pull every
|
||||
/// matching row on every poll. The plan now emits <c>SELECT TOP (2) …</c> — proven valid T-SQL here —
|
||||
/// and the second row makes the tag publish <c>BadConfigurationError</c> while the driver stays
|
||||
/// Healthy (the server answered). The positive control: a station with exactly one row reads Good
|
||||
/// through the same capped statement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realSqlServer_anAmbiguousSelectorIsRowCappedToAConfigurationError()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
const string table = "RowCapEvents";
|
||||
|
||||
await using (var arrange = await _sql.OpenInspectionConnectionAsync())
|
||||
{
|
||||
await using var command = arrange.CreateCommand();
|
||||
command.CommandText = string.Create(CultureInfo.InvariantCulture, $"""
|
||||
IF OBJECT_ID(N'{SqlPollServerFixture.Schema}.{table}') IS NOT NULL DROP TABLE {SqlPollServerFixture.Schema}.{table};
|
||||
CREATE TABLE {SqlPollServerFixture.Schema}.{table} (
|
||||
{SqlPollServerFixture.WideRowSelectorColumn} int NOT NULL,
|
||||
oven_temp float NULL,
|
||||
{SqlPollServerFixture.TimestampColumn} datetime2 NOT NULL);
|
||||
INSERT INTO {SqlPollServerFixture.Schema}.{table} VALUES (1, 21.5, '2026-07-24T10:00:00');
|
||||
DECLARE @i int = 0;
|
||||
WHILE @i < 40
|
||||
BEGIN
|
||||
INSERT INTO {SqlPollServerFixture.Schema}.{table} VALUES (7, @i, DATEADD(second, @i, '2026-07-24T10:00:00'));
|
||||
SET @i += 1;
|
||||
END
|
||||
""");
|
||||
await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var driver = await StartAsync(
|
||||
WideRowWhere("Sql/Events/Ambiguous", "oven_temp", "7", table),
|
||||
WideRowWhere("Sql/Events/Clean", "oven_temp", "1", table));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Events/Ambiguous", "Sql/Events/Clean"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[1].Value).ShouldBe(21.5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var cleanup = await _sql.OpenInspectionConnectionAsync();
|
||||
await using var drop = cleanup.CreateCommand();
|
||||
drop.CommandText = $"DROP TABLE IF EXISTS {SqlPollServerFixture.Schema}.{table}";
|
||||
await drop.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I-1, live: one stray duplicate row in a key-value table fails ONLY that key's tag; the other
|
||||
/// key in the same group (one capped <c>SELECT TOP (5) … IN (@k0, @k1)</c>) reads Good — the positive
|
||||
/// control — and the driver stays Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_realSqlServer_aDuplicatedKeyFailsOnlyItsOwnTag()
|
||||
{
|
||||
SkipUnlessLive();
|
||||
const string table = "RowCapCurrent";
|
||||
var qualified = $"{SqlPollServerFixture.Schema}.{table}";
|
||||
|
||||
await using (var arrange = await _sql.OpenInspectionConnectionAsync())
|
||||
{
|
||||
await using var command = arrange.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
IF OBJECT_ID(N'{qualified}') IS NOT NULL DROP TABLE {qualified};
|
||||
CREATE TABLE {qualified} (
|
||||
{SqlPollServerFixture.KeyColumn} nvarchar(64) NOT NULL,
|
||||
{SqlPollServerFixture.ValueColumn} float NULL,
|
||||
{SqlPollServerFixture.TimestampColumn} datetime2 NOT NULL);
|
||||
INSERT INTO {qualified} VALUES
|
||||
(N'Pump7', 1.0, '2026-07-24T10:00:00'),
|
||||
(N'Pump7', 2.0, '2026-07-24T10:00:01'),
|
||||
(N'Pump8', 8.5, '2026-07-24T10:00:00');
|
||||
""";
|
||||
await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var driver = await StartAsync(
|
||||
KeyValueOn("Sql/Current/Pump7", "Pump7", table),
|
||||
KeyValueOn("Sql/Current/Pump8", "Pump8", table));
|
||||
|
||||
var snapshots = await driver.ReadAsync(
|
||||
["Sql/Current/Pump7", "Sql/Current/Pump8"], TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
Convert.ToDouble(snapshots[1].Value).ShouldBe(8.5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var cleanup = await _sql.OpenInspectionConnectionAsync();
|
||||
await using var drop = cleanup.CreateCommand();
|
||||
drop.CommandText = $"DROP TABLE IF EXISTS {qualified}";
|
||||
await drop.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static RawTagEntry KeyValueOn(string rawPath, string key, string table) => Tag(rawPath, new JsonObject
|
||||
{
|
||||
["driver"] = "Sql",
|
||||
["model"] = "KeyValue",
|
||||
["table"] = SqlPollServerFixture.Qualified(table),
|
||||
["keyColumn"] = SqlPollServerFixture.KeyColumn,
|
||||
["keyValue"] = key,
|
||||
["valueColumn"] = SqlPollServerFixture.ValueColumn,
|
||||
["timestampColumn"] = SqlPollServerFixture.TimestampColumn,
|
||||
});
|
||||
|
||||
// ---- key-value model ----
|
||||
|
||||
/// <summary>The plan's headline case: a seeded key-value row round-trips as a Good snapshot.</summary>
|
||||
|
||||
@@ -77,6 +77,10 @@ public sealed class ColonMarkerDialect : ISqlDialect
|
||||
/// <inheritdoc/>
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
|
||||
|
||||
|
||||
@@ -210,6 +210,15 @@ public class OracleDialectTests
|
||||
public void LivenessSql_selectsFromDual()
|
||||
=> new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
|
||||
|
||||
[Fact]
|
||||
public void RowLimit_isFetchFirstN_inTheSuffixPosition()
|
||||
{
|
||||
var d = new OracleDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("");
|
||||
d.RowLimitSuffix(3).ShouldBe(" FETCH FIRST 3 ROWS ONLY");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitSuffix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
|
||||
@@ -226,6 +226,15 @@ public class PostgresDialectTests
|
||||
d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowLimit_isLimitN_inTheSuffixPosition()
|
||||
{
|
||||
var d = new PostgresDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("");
|
||||
d.RowLimitSuffix(3).ShouldBe(" LIMIT 3");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitSuffix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace()
|
||||
{
|
||||
|
||||
@@ -113,6 +113,10 @@ public sealed class SqlCatalogDefaultSchemaTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql => twoSchemas
|
||||
? Inner.ListSchemasSql + " UNION ALL SELECT 'other' AS TABLE_SCHEMA"
|
||||
: Inner.ListSchemasSql;
|
||||
|
||||
@@ -374,6 +374,10 @@ public sealed class SqlCatalogGateDriverTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql { get; } = listSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
@@ -25,6 +25,28 @@ public sealed class SqlDriverFactoryTests
|
||||
{
|
||||
// ---- the plan's headline legs ----
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-8.</b> A malformed config used to quote System.Text.Json's message — which echoes the
|
||||
/// offending token — into the exception that becomes <c>InitializeFailed</c> → health
|
||||
/// <c>LastError</c> → <c>/hosts</c>. A pasted connection string is exactly the malformed text that
|
||||
/// reaches here, so the parser text (and the inner exception carrying it) is withheld, as the probe and
|
||||
/// browser already do. The message still names the instance and says what is wrong.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_malformedJson_withholdsTheParserText()
|
||||
{
|
||||
const string secret = "hunter2-do-not-log";
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
SqlDriverFactoryExtensions.CreateInstance(
|
||||
"d1", $$"""{"provider":"SqlServer","connectionStringRef": Password={{secret}} }""", null));
|
||||
|
||||
ex.Message.ShouldContain("d1");
|
||||
ex.Message.ShouldContain("not valid JSON");
|
||||
ex.Message.ShouldNotContain(secret);
|
||||
ex.Message.ShouldNotContain("Password");
|
||||
ex.InnerException.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_missingConnectionStringRef_throwsActionable()
|
||||
=> Should.Throw<InvalidOperationException>(() =>
|
||||
|
||||
@@ -272,14 +272,15 @@ public sealed class SqlDriverTests
|
||||
// ---- the comms-loss watchdog's evidence clock (Driver.Sql-006) ----
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_withoutChangingHealth()
|
||||
public async Task ReadAsync_whenTheDatabaseAnswersWithNoRow_advancesTheEvidenceClock_andRestoresHealthy()
|
||||
{
|
||||
// Driver.Sql-006. LastSuccessfulRead is what DriverInstanceActor's comms-loss watchdog reads: a
|
||||
// non-Healthy driver whose clock has been frozen for 90 s is force-reconnected (Gitea #527), on the
|
||||
// premise that the clock is the honest evidence the transport works. BadNoData IS that evidence —
|
||||
// the query ran and the row is absent — so a driver whose key set was deleted must not be
|
||||
// reconnected every 90 s against a database answering every poll on time. The health STATE stays
|
||||
// exactly as it was: an absent row is a data fact, not a connectivity one.
|
||||
// reconnected every 90 s against a database answering every poll on time. And since the database
|
||||
// answered, the driver is Healthy again (lane rule: a refusal means the device answered — it used to
|
||||
// leave the Degraded latched behind every refusal-only poll).
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Missing", SqlitePollFixture.AbsentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
@@ -295,7 +296,7 @@ public sealed class SqlDriverTests
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
var health = driver.GetHealth();
|
||||
health.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); // RED pre-fix: frozen
|
||||
health.State.ShouldBe(DriverState.Degraded); // classification untouched
|
||||
health.State.ShouldBe(DriverState.Healthy); // the database answered
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -356,6 +357,191 @@ public sealed class SqlDriverTests
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-1.</b> An over-cap result set publishes BadConfigurationError — the code the planner also
|
||||
/// mints before any I/O, which on its own is NOT answer evidence (theory above). The reader's
|
||||
/// answered-refusal count is what makes it count: the database answered, so the clock advances and a
|
||||
/// comms-Degraded driver is Healthy again. Control: the same snapshot with no count leaves both alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ObservePollOutcome_aRowCapRefusal_isAnswerEvidence_andRestoresHealthy()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var refused = new DataValueSnapshot(null, SqlStatusCodes.BadConfigurationError, null, DateTime.UtcNow);
|
||||
|
||||
driver.ObservePollOutcome([new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, null, DateTime.UtcNow)]);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
|
||||
driver.ObservePollOutcome([refused]); // no count: planner-minted, no evidence
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldBe(before);
|
||||
|
||||
driver.ObservePollOutcome([refused], answeredRefusals: 1); // the database answered, over-cap
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-9 / lifecycle generations.</b> A poll that started before a Shutdown → Initialize cycle
|
||||
/// (it can outlive the engine's 5 s teardown wait) must not write its verdict onto the NEW
|
||||
/// configuration's health. Control: the same BadTimeout verdict under the current generation degrades.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ObservePollOutcome_fromASupersededGeneration_isIgnored()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var stale = driver.LifecycleGenerationForTests;
|
||||
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
var timedOut = new DataValueSnapshot(null, SqlStatusCodes.BadTimeout, null, DateTime.UtcNow);
|
||||
|
||||
driver.ObservePollOutcome([timedOut], generation: stale);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "a superseded poll must not degrade the new init");
|
||||
|
||||
driver.ObservePollOutcome([timedOut], generation: driver.LifecycleGenerationForTests);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only the initialize's own final write ends <c>Initializing</c>: a Good poll landing mid-init used to
|
||||
/// flip it to Healthy before liveness had even answered. Control: once the init completes it is Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task APollLandingMidInitialize_cannotEndInitializing()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var factory = new GatedFactory();
|
||||
await using var driver = NewDriver(fixture, fixture.ConnectionString, CapturingLogger.Null, factory,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
|
||||
factory.Arm();
|
||||
var init = driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
(await factory.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)).ShouldBeTrue();
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Initializing);
|
||||
|
||||
driver.ObservePollOutcome([new DataValueSnapshot(42.0, SqlStatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow)]);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Initializing);
|
||||
|
||||
factory.Release();
|
||||
await init.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A SQLite factory whose next connection creation blocks until released — parks an initialize.</summary>
|
||||
private sealed class GatedFactory : DbProviderFactory
|
||||
{
|
||||
private readonly ManualResetEventSlim _release = new(initialState: true);
|
||||
private volatile bool _armed;
|
||||
|
||||
public TaskCompletionSource<bool> Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void Arm()
|
||||
{
|
||||
_release.Reset();
|
||||
_armed = true;
|
||||
}
|
||||
|
||||
public void Release() => _release.Set();
|
||||
|
||||
public override DbConnection CreateConnection()
|
||||
{
|
||||
if (_armed)
|
||||
{
|
||||
_armed = false;
|
||||
Entered.TrySetResult(true);
|
||||
_release.Wait(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
return Microsoft.Data.Sqlite.SqliteFactory.Instance.CreateConnection()!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-4.</b> A skipped tag's ledger reason (what /hosts shows) is the deploy gate's own explanation
|
||||
/// — for a <c>Query</c> tag, that the model is deferred — instead of a generic "did not map". Control: a
|
||||
/// well-formed tag is not skipped at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ADeferredQueryTag_isSkipped_withTheGatesReason()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey),
|
||||
new RawTagEntry("Q", """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""", WriteIdempotent: false));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var report = ((ISkippedTagReporter)driver).GetSkippedTags().ShouldNotBeNull();
|
||||
var skipped = report.Sample.ShouldHaveSingleItem();
|
||||
skipped.RawPath.ShouldBe("Q");
|
||||
skipped.Reason.ShouldContain("Query");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Review m-1.</b> A redeploy re-initialises without teardown, so a poll can capture the reader just
|
||||
/// before a rebind disposes it. That poll used to throw an OperationCanceledException the engine then
|
||||
/// classified as a poll failure (Degrade). It now retries once on the current reader — and reads Good,
|
||||
/// the positive control — with health untouched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task APollOnAReplacedReader_retriesOnTheCurrentOne()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
var captured = driver.ReaderForTests;
|
||||
|
||||
driver.RebindForTests();
|
||||
captured.GateDisposedForTests.ShouldBeTrue();
|
||||
|
||||
var snapshots = await driver.ReadThroughAsync(
|
||||
captured, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
|
||||
|
||||
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no newer reader (the driver's own reader was disposed) the disposed-reader exception still
|
||||
/// propagates, but the engine's error callback must not degrade for it. Control: any other poll error
|
||||
/// still degrades.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ADisposedReaderError_isNotAPollFailure()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
driver.ReaderForTests.Dispose();
|
||||
|
||||
// Awaited directly (as the poll engine does): an OperationCanceledException-derived throw ends the task
|
||||
// Canceled, and only a direct await rethrows the original instance — which HandlePollError matches on.
|
||||
Exception? caught = null;
|
||||
try
|
||||
{
|
||||
await driver.ReadThroughAsync(
|
||||
driver.ReaderForTests, ["Speed"], driver.LifecycleGenerationForTests, CancellationToken.None);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
caught = e;
|
||||
}
|
||||
|
||||
caught.ShouldBeOfType<SqlPollReaderDisposedException>();
|
||||
driver.HandlePollError(caught!);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
driver.HandlePollError(new InvalidOperationException("contract violation"));
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
// ---- Driver.Sql-010: the health field is read-modify-written from several poll loops ----
|
||||
|
||||
[Fact]
|
||||
@@ -445,6 +631,31 @@ public sealed class SqlDriverTests
|
||||
Volatile.Read(ref changes).ShouldBe(afterUnsubscribe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R02:P1.</b> The driver runs <c>PollGroupEngine</c>, so it raises one batch per poll burst:
|
||||
/// <c>DriverInstanceActor</c> then forwards one message per cycle instead of one per tag. Both tags of
|
||||
/// the first (force-raised) poll arrive in the SAME batch, on the subscription's handle.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_raisesOneBatchPerPollBurst()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
await using var driver = NewDriver(fixture,
|
||||
KvEntry("Speed", SqlitePollFixture.PresentKey), KvEntry("Pressure", SqlitePollFixture.SecondPresentKey));
|
||||
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
|
||||
|
||||
var batchable = driver.ShouldBeAssignableTo<IBatchSubscribable>();
|
||||
var first = new TaskCompletionSource<DataChangeBatchEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
batchable!.OnDataChangeBatch += (_, args) => first.TrySetResult(args);
|
||||
|
||||
var handle = await driver.SubscribeAsync(["Speed", "Pressure"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
|
||||
var batch = await first.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
batch.SubscriptionHandle.ShouldBe(handle);
|
||||
batch.Items.Select(i => i.FullReference).OrderBy(r => r, StringComparer.Ordinal).ShouldBe(["Pressure", "Speed"]);
|
||||
batch.Items.Single(i => i.FullReference == "Speed").Snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
}
|
||||
|
||||
// ---- disposal ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -232,6 +232,21 @@ public class SqlEquipmentTagParserTests
|
||||
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-4.</b> The deploy gate (<c>EquipmentTagConfigInspector</c>) reaches this through the parser:
|
||||
/// a <c>Query</c> tag draws a warning naming the deferral (a rejection under TagConfigValidation=Error).
|
||||
/// Control: a served key-value blob draws nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Inspect_theDeferredQueryModel_isNamedAtTheDeployGate()
|
||||
{
|
||||
SqlTagDefinitionFactory.Inspect("""{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""")
|
||||
.ShouldHaveSingleItem().ShouldContain("Query");
|
||||
SqlTagDefinitionFactory.Inspect(
|
||||
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
|
||||
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
|
||||
|
||||
@@ -150,7 +150,7 @@ public sealed class SqlGroupPlannerChunkingTests
|
||||
plan.ParameterNames.ShouldBe(
|
||||
Enumerable.Range(0, plan.Parameters.Count)
|
||||
.Select(i => "@k" + i.ToString(CultureInfo.InvariantCulture)));
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
plan.SqlText.ShouldContain("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,10 @@ public sealed class SqlGroupPlannerChunkingTests
|
||||
|
||||
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
|
||||
|
||||
public string RowLimitPrefix(int rows) => Inner.RowLimitPrefix(rows);
|
||||
|
||||
public string RowLimitSuffix(int rows) => Inner.RowLimitSuffix(rows);
|
||||
|
||||
public string ListSchemasSql => Inner.ListSchemasSql;
|
||||
|
||||
public string DefaultSchemaSql => Inner.DefaultSchemaSql;
|
||||
|
||||
@@ -29,6 +29,39 @@ public class SqlGroupPlannerTests
|
||||
RowSelectorColumn: selectorColumn, RowSelectorValue: selectorValue,
|
||||
RowSelectorTopByTimestamp: topByTimestamp);
|
||||
|
||||
// ---- R06:S-1: the row cap ----
|
||||
|
||||
/// <summary>
|
||||
/// A key-value plan reads up to twice its distinct keys (so stray duplicates are attributed per key) and
|
||||
/// asks for one row more; a where-pair wide-row plan asks for two. Enough to SEE an ambiguous selector,
|
||||
/// never enough to materialise a history table every poll.
|
||||
/// The topByTimestamp form keeps its single-row limit and needs no cap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Plans_carryARowCap_andAskForOneRowMore()
|
||||
{
|
||||
var plans = SqlGroupPlanner.Plan(
|
||||
[
|
||||
KvTag("A", "dbo.TagValues", "tag_name", "k0", "num_value", null),
|
||||
KvTag("B", "dbo.TagValues", "tag_name", "k1", "num_value", null),
|
||||
KvTag("C", "dbo.TagValues", "tag_name", "k1", "num_value", null), // same key: one row serves both
|
||||
WideTag("W", "dbo.LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7"),
|
||||
WideTag("T", "dbo.Readings", "oven_temp", topByTimestamp: "sample_ts"),
|
||||
], new SqlServerDialect()).ToList();
|
||||
|
||||
var kv = plans.Single(p => p.Model == SqlTagModel.KeyValue);
|
||||
kv.RowCap.ShouldBe(4); // 2 distinct keys, with 2x headroom for per-key attribution
|
||||
kv.SqlText.ShouldStartWith("SELECT TOP (5) ");
|
||||
|
||||
var wherePair = plans.Single(p => p.Parameters.Count == 1 && p.Model == SqlTagModel.WideRow);
|
||||
wherePair.RowCap.ShouldBe(1);
|
||||
wherePair.SqlText.ShouldStartWith("SELECT TOP (2) ");
|
||||
|
||||
var top = plans.Single(p => p.Parameters.Count == 0);
|
||||
top.RowCap.ShouldBeNull();
|
||||
top.SqlText.ShouldStartWith("SELECT TOP 1 ");
|
||||
}
|
||||
|
||||
// ---- the plan's golden case ----
|
||||
|
||||
[Fact]
|
||||
@@ -58,7 +91,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [num_value], [sample_ts] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { "@k0" });
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "tag_name", "num_value", "sample_ts" });
|
||||
plans[0].Model.ShouldBe(SqlTagModel.KeyValue);
|
||||
@@ -75,7 +108,7 @@ public class SqlGroupPlannerTests
|
||||
new SqlServerDialect());
|
||||
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [num_value] FROM [dbo].[TagValues] WHERE [tag_name] IN (@k0)");
|
||||
plans[0].TimestampColumn.ShouldBeNull();
|
||||
}
|
||||
|
||||
@@ -181,7 +214,7 @@ public class SqlGroupPlannerTests
|
||||
|
||||
var plan = plans[0];
|
||||
plan.ParameterNames.Count.ShouldBe(plan.Parameters.Count);
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")");
|
||||
plan.SqlText.ShouldEndWith("IN (" + string.Join(", ", plan.ParameterNames) + ")"); // T-SQL caps at the front
|
||||
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
|
||||
}
|
||||
|
||||
@@ -201,7 +234,7 @@ public class SqlGroupPlannerTests
|
||||
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
|
||||
], new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
|
||||
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1) LIMIT 5");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
|
||||
}
|
||||
@@ -213,7 +246,7 @@ public class SqlGroupPlannerTests
|
||||
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
|
||||
new ColonMarkerDialect()).ToList();
|
||||
|
||||
plans[0].SqlText.ShouldEndWith("= :w");
|
||||
plans[0].SqlText.ShouldEndWith("= :w LIMIT 2");
|
||||
plans[0].SqlText.ShouldNotContain("@");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
|
||||
}
|
||||
@@ -277,7 +310,7 @@ public class SqlGroupPlannerTests
|
||||
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
"SELECT TOP (2) [oven_temp], [pressure] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
plans[0].ParameterNames.ShouldBe(new[] { "@w" });
|
||||
plans[0].Parameters.ShouldBe(new object[] { "7" }); // the whereValue is bound, never inlined
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure" });
|
||||
@@ -313,7 +346,7 @@ public class SqlGroupPlannerTests
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SelectedColumns.ShouldBe(new[] { "oven_temp", "pressure", "sample_ts" });
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
"SELECT TOP (2) [oven_temp], [pressure], [sample_ts] FROM [dbo].[LatestStatus] WHERE [station_id] = @w");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -424,7 +457,7 @@ public class SqlGroupPlannerTests
|
||||
// A column name is an identifier — it cannot be parameterized, so the guarantee is that it is
|
||||
// bracket-quoted with ] doubled, leaving one (nonexistent) identifier rather than executable SQL.
|
||||
plans[0].SqlText.ShouldBe(
|
||||
"SELECT [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
"SELECT TOP (3) [tag_name], [v]] ; DROP TABLE Users --] FROM [dbo].[T] WHERE [tag_name] IN (@k0)");
|
||||
}
|
||||
|
||||
// ---- contract edges ----
|
||||
|
||||
@@ -187,7 +187,7 @@ public sealed class SqlPollReaderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne()
|
||||
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_publishesAConfigurationError_andWarns()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
// A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so
|
||||
@@ -201,8 +201,11 @@ public sealed class SqlPollReaderTests
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
|
||||
|
||||
// Still a value — the reader degrades to "last row wins", exactly as the key-value model does.
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
// R06:S-1: the where-pair plan now asks for TWO rows (one more than it can use). Getting the second is
|
||||
// proof the selector is ambiguous, so the group publishes a configuration error rather than a
|
||||
// storage-order-dependent "last row" value — and nothing past that second row is ever read.
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[0].Value.ShouldBeNull();
|
||||
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Level.ShouldBe(LogLevel.Warning);
|
||||
@@ -269,8 +272,48 @@ public sealed class SqlPollReaderTests
|
||||
snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-1 review I-1.</b> One stray duplicate row is attributed to ITS key: that key's tags publish
|
||||
/// <see cref="SqlStatusCodes.BadConfigurationError"/> and every other key in the group reads normally.
|
||||
/// It used to be last-row-wins Good, or the whole group Bad, depending on whether an UNRELATED key was
|
||||
/// absent. Both variants are pinned: every other key present, and another key absent. The other keys'
|
||||
/// Good / BadNoData are the positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task ReadAsync_aDuplicatedKey_failsOnlyThatKeysTags_whateverElseIsAbsent(bool withAnAbsentKey)
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
fixture.Execute(
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
||||
var tags = new List<SqlTagDefinition>
|
||||
{
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||
KvTag("Speed2", SqlitePollFixture.PresentKey), // a second tag on the duplicated key
|
||||
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
|
||||
};
|
||||
if (withAnAbsentKey) tags.Add(KvTag("Gone", SqlitePollFixture.AbsentKey));
|
||||
var reader = Reader(fixture, logger, [.. tags]);
|
||||
|
||||
var snapshots = await reader.ReadAsync([.. tags.Select(t => t.Name)], CancellationToken.None);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
if (withAnAbsentKey) snapshots[3].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
|
||||
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Message.ShouldContain(SqlitePollFixture.PresentKey);
|
||||
}
|
||||
|
||||
/// <summary>The driver counts a duplicated-key refusal as the database answering, like an over-cap one.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically()
|
||||
public async Task ReadDetailedAsync_aDuplicatedKey_isReportedAsAnAnsweredRefusal()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
fixture.Execute(
|
||||
@@ -278,12 +321,86 @@ public sealed class SqlPollReaderTests
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
|
||||
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
var clean = Reader(fixture, KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
|
||||
(await reader.ReadDetailedAsync(["Speed"], CancellationToken.None)).AnsweredRefusalGroups.ShouldBe(1);
|
||||
(await clean.ReadDetailedAsync(["Pressure"], CancellationToken.None)).AnsweredRefusalGroups.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
|
||||
// deterministic — last occurrence in reader order — rather than silently arbitrary.
|
||||
snapshots[0].Value.ShouldBe(99.0);
|
||||
/// <summary>
|
||||
/// <b>R06:S-1.</b> A key-value tag pointed at a history table (many rows per key) used to pull every
|
||||
/// matching row into memory on every poll. The plan now asks for one row more than it has keys; a full
|
||||
/// result set proves the selector is ambiguous, so every member publishes a configuration error and a
|
||||
/// (rate-limited) warning names the table. The positive control: the same tag against a clean source
|
||||
/// reads Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_keyValueRowCapExceeded_publishesAConfigurationError_forEveryMember()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var clean = Reader(fixture, logger, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
(await clean.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
logger.Entries.ShouldBeEmpty();
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
fixture.Execute(string.Create(CultureInfo.InvariantCulture,
|
||||
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
|
||||
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
|
||||
$"VALUES ('{SqlitePollFixture.PresentKey}', {i}, '2026-07-24T11:00:00Z')"));
|
||||
}
|
||||
|
||||
var reader = Reader(fixture, logger, KvTag("Speed", SqlitePollFixture.PresentKey), KvTag("Speed2", SqlitePollFixture.PresentKey));
|
||||
var snapshots = await reader.ReadAsync(["Speed", "Speed2"], CancellationToken.None);
|
||||
|
||||
snapshots.ShouldAllBe(x => x.StatusCode == SqlStatusCodes.BadConfigurationError);
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Level.ShouldBe(LogLevel.Warning);
|
||||
warning.Message.ShouldContain(SqlitePollFixture.KeyValueTable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client-side half of the cap: a dialect whose limit fragments are empty (a provider that cannot
|
||||
/// spell one) must still stop reading at cap + 1 and report the same configuration error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_aDialectThatEmitsNoLimit_isStillCappedClientSide()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
AddSecondRowForPresentStation(fixture);
|
||||
var reader = new SqlPollReader(fixture.Factory, fixture.ConnectionString, new NoLimitDialect(),
|
||||
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||
maxConcurrentGroups: 4, nullIsBad: false,
|
||||
resolve: Resolver(WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
|
||||
|
||||
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
|
||||
|
||||
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
|
||||
}
|
||||
|
||||
/// <summary>SQLite with the row-limit fragments blanked — the "provider cannot spell a limit" shape.</summary>
|
||||
private sealed class NoLimitDialect : ISqlDialect
|
||||
{
|
||||
private readonly SqliteDialect _inner = new();
|
||||
public SqlProvider Provider => _inner.Provider;
|
||||
public System.Data.Common.DbProviderFactory Factory => _inner.Factory;
|
||||
public int MaxBindParameters => _inner.MaxBindParameters;
|
||||
public string LivenessSql => _inner.LivenessSql;
|
||||
public string SingleRowLimitPrefix => _inner.SingleRowLimitPrefix;
|
||||
public string SingleRowLimitSuffix => _inner.SingleRowLimitSuffix;
|
||||
public string RowLimitPrefix(int rows) => string.Empty;
|
||||
public string RowLimitSuffix(int rows) => string.Empty;
|
||||
public string ListSchemasSql => _inner.ListSchemasSql;
|
||||
public string DefaultSchemaSql => _inner.DefaultSchemaSql;
|
||||
public string ListTablesSql => _inner.ListTablesSql;
|
||||
public string ListColumnsSql => _inner.ListColumnsSql;
|
||||
public string QuoteIdentifier(string ident) => _inner.QuoteIdentifier(ident);
|
||||
public string ParameterMarker(string baseName) => _inner.ParameterMarker(baseName);
|
||||
public string ParameterBindName(string baseName) => _inner.ParameterBindName(baseName);
|
||||
public void ConfigureCommand(System.Data.Common.DbCommand command) => _inner.ConfigureCommand(command);
|
||||
public void ConfigureParameter(System.Data.Common.DbParameter parameter, object? value) => ((ISqlDialect)_inner).ConfigureParameter(parameter, value);
|
||||
public DriverDataType MapColumnType(string sqlDataType) => _inner.MapColumnType(sqlDataType);
|
||||
}
|
||||
|
||||
// ---- the frozen-peer contract ----
|
||||
@@ -522,6 +639,180 @@ public sealed class SqlPollReaderTests
|
||||
dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null));
|
||||
}
|
||||
|
||||
// ---- R06:S-7: plans are cached per subscription reference list ----
|
||||
|
||||
/// <summary>
|
||||
/// A poll loop hands the reader the SAME reference list every cycle; re-planning it (and rebuilding the
|
||||
/// slot dictionary keyed by a 12-field record) every poll was pure per-poll garbage. Planned once,
|
||||
/// and the cached plan still reads correct values every time — the positive control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_theSameReferenceList_isPlannedOnce()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var reader = Reader(fixture,
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey), KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
|
||||
IReadOnlyList<string> refs = ["Speed", "Pressure"];
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var snapshots = await reader.ReadAsync(refs, CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
}
|
||||
|
||||
reader.PlansBuiltForTests.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cache is invalidated by the definitions themselves: a catalog-gate swap (or any re-resolve)
|
||||
/// hands back different definition instances, and the next poll re-plans against them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_whenAReferenceResolvesToADifferentDefinition_rePlans()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var table = new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal)
|
||||
{
|
||||
["Speed"] = KvTag("Speed", SqlitePollFixture.PresentKey),
|
||||
};
|
||||
var reader = new SqlPollReader(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
|
||||
maxConcurrentGroups: 4, nullIsBad: false, resolve: r => table.GetValueOrDefault(r));
|
||||
IReadOnlyList<string> refs = ["Speed"];
|
||||
|
||||
(await reader.ReadAsync(refs, CancellationToken.None))[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
|
||||
table["Speed"] = KvTag("Speed", SqlitePollFixture.SecondPresentKey); // re-pointed by a new resolve
|
||||
|
||||
(await reader.ReadAsync(refs, CancellationToken.None))[0].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
|
||||
reader.PlansBuiltForTests.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---- R06:S-6: per-poll warnings are rate-limited ----
|
||||
|
||||
/// <summary>
|
||||
/// A dropped table polled every second used to log a warning — with the full SQL text — on every
|
||||
/// poll, per group, forever. One per minute per plan now; every poll still publishes the Bad status.
|
||||
/// The first poll's warning is the positive control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_aPersistentlyFailingGroup_warnsOncePerInterval()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var reader = Reader(fixture, logger,
|
||||
new SqlTagDefinition("Gone", SqlTagModel.KeyValue, "NoSuchTable",
|
||||
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: "k", ValueColumn: SqlitePollFixture.ValueColumn));
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
(await reader.ReadAsync(["Gone"], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
|
||||
}
|
||||
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A cell that will not coerce is a per-tag refusal: one warning per tag, not one per poll.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_anUncoercibleCell_warnsOncePerTag()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var reader = Reader(fixture, logger,
|
||||
KvTag("Speed", SqlitePollFixture.PresentKey, declaredType: DriverDataType.DateTime));
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch);
|
||||
}
|
||||
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A frozen group's BadTimeout warning is rate-limited too; both polls still time out.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_aRepeatedlyTimedOutGroup_warnsOncePerInterval()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var logger = new RecordingLogger();
|
||||
var reader = new SqlPollReader(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
|
||||
maxConcurrentGroups: 4, nullIsBad: false,
|
||||
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)), logger: logger);
|
||||
|
||||
var locker = fixture.OpenNewConnection();
|
||||
Execute(locker, "BEGIN EXCLUSIVE");
|
||||
try
|
||||
{
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Execute(locker, "ROLLBACK");
|
||||
locker.Dispose();
|
||||
}
|
||||
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("operation timeout"))
|
||||
.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---- R06:S-3: the concurrency gate is released ----
|
||||
|
||||
/// <summary>An idle reader releases its gate on Dispose, and a disposed reader starts no new group.</summary>
|
||||
[Fact]
|
||||
public async Task Dispose_releasesTheGate_andADisposedReaderRunsNoNewGroup()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
|
||||
|
||||
reader.Dispose();
|
||||
reader.Dispose(); // idempotent
|
||||
|
||||
reader.GateDisposedForTests.ShouldBeTrue();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => reader.ReadAsync(["Speed"], CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposing while a timed-out group is still wedged inside the provider must NOT dispose the gate
|
||||
/// under it — the zombie releases its slot into the gate when it finally finishes, which is exactly
|
||||
/// the hazard that kept the gate undisposed before. The gate goes once the zombie is out.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_withAWedgedGroupInFlight_waitsForItBeforeReleasingTheGate()
|
||||
{
|
||||
using var fixture = new SqlitePollFixture();
|
||||
var factory = new TrackingFactory();
|
||||
var reader = new SqlPollReader(
|
||||
factory, fixture.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
|
||||
maxConcurrentGroups: 1, nullIsBad: false,
|
||||
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
|
||||
|
||||
var locker = fixture.OpenNewConnection();
|
||||
Execute(locker, "BEGIN EXCLUSIVE");
|
||||
try
|
||||
{
|
||||
(await reader.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
|
||||
factory.Live.ShouldBe(1); // the zombie is still inside the provider — the control
|
||||
|
||||
reader.Dispose();
|
||||
reader.GateDisposedForTests.ShouldBeFalse("a group still holds the gate");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Execute(locker, "ROLLBACK");
|
||||
locker.Dispose();
|
||||
}
|
||||
|
||||
(await WaitUntilAsync(() => reader.GateDisposedForTests, TimeSpan.FromSeconds(30)))
|
||||
.ShouldBeTrue("the last group out must release the gate");
|
||||
factory.Live.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_rejectsANullReferenceList()
|
||||
=> await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
|
||||
@@ -81,6 +81,11 @@ public sealed class SqlReinitConfigAdoptionTests
|
||||
"old-server",
|
||||
customMessage:
|
||||
"a reinitialize that could not resolve its new connection must leave the previous binding whole");
|
||||
|
||||
// Review m-4: the failure is a fault with a reason, never a latched Initializing with no LastError.
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(Core.Abstractions.DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNull().ShouldContain("could not adopt its configuration");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle
|
||||
@@ -101,6 +106,43 @@ public sealed class SqlReinitConfigAdoptionTests
|
||||
driver.Endpoint.ShouldContain("old-server");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>R06:S-3.</b> Every rebind builds a new <see cref="SqlPollReader"/>; the replaced one (and its
|
||||
/// <c>SemaphoreSlim</c>) used to be dropped undisposed, one per reinitialize. The control: the reader
|
||||
/// that replaced it is live.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_rebind_disposes_the_reader_it_replaces()
|
||||
{
|
||||
using var _ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
|
||||
"Server=old-server;Database=OldDb;Integrated Security=true");
|
||||
using var __ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB,
|
||||
"Server=new-server;Database=NewDb;Integrated Security=true");
|
||||
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
|
||||
var original = driver.ReaderForTests;
|
||||
|
||||
try
|
||||
{
|
||||
await driver.ReinitializeAsync(
|
||||
$$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Liveness failure against a non-existent server is expected; the rebind ran first.
|
||||
}
|
||||
|
||||
driver.ReaderForTests.ShouldNotBeSameAs(original);
|
||||
original.GateDisposedForTests.ShouldBeTrue();
|
||||
driver.ReaderForTests.GateDisposedForTests.ShouldBeFalse();
|
||||
|
||||
await driver.DisposeAsync();
|
||||
driver.ReaderForTests.GateDisposedForTests.ShouldBeTrue("the final dispose releases the live reader too");
|
||||
}
|
||||
|
||||
/// <summary>Sets an environment variable for the duration of a test and restores it after. The resolver
|
||||
/// reads process-global state, so a leaked variable would race every sibling test.</summary>
|
||||
private sealed class ScopedEnvironmentVariable : IDisposable
|
||||
|
||||
@@ -13,6 +13,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
/// </summary>
|
||||
public class SqlServerDialectTests
|
||||
{
|
||||
[Fact]
|
||||
public void RowLimit_isTopN_inThePrefixPosition()
|
||||
{
|
||||
var d = new SqlServerDialect();
|
||||
d.RowLimitPrefix(3).ShouldBe("TOP (3) ");
|
||||
d.RowLimitSuffix(3).ShouldBe("");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => d.RowLimitPrefix(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
|
||||
{
|
||||
|
||||
@@ -87,6 +87,20 @@ public sealed class SqliteDialect : ISqlDialect
|
||||
/// </summary>
|
||||
public string SingleRowLimitSuffix => " LIMIT 1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitPrefix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RowLimitSuffix(int rows)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
|
||||
return " LIMIT " + rows.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SQLite has no schema namespace, so the schema level is the single literal
|
||||
/// <see cref="MainSchema"/> — enough to give the browser a real root to expand.
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s TwinCAT row against the REAL write path (#739, review 07 U9), the
|
||||
/// same way the Modbus, S7 and Galaxy parity tests do: the server advertises an array node writable exactly when
|
||||
/// <see cref="TwinCATDriver.WriteAsync"/> converts a whole-array value (<c>TwinCATArrayCodec.ToWriteElements</c>)
|
||||
/// and hands it to <c>WriteArrayAsync</c>, AND the production <see cref="TwinCATArrayCodec.Encode"/> lays the
|
||||
/// converted elements out. A driver change that adds or drops an array element type fails here until the table
|
||||
/// follows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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). Every array node is materialised 1-D; a
|
||||
/// multi-dimensional PLC array is written row-major from that flat value, so nothing here considers more than one
|
||||
/// rank. The PLC-side shape check (<c>CheckArray</c>: declared length ≤ the PLC's element count, element width)
|
||||
/// runs against the resolved symbol at write time and is outside what a compose-time table can know.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATArrayWriteSupportParityTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
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(),
|
||||
// Non-numeric text: a String node over a non-string element converts only when the text happens to parse,
|
||||
// which is not a write the server can promise — the table keeps it read-only.
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc), 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"];
|
||||
|
||||
// A PLC element width the codec can lay each type out at (STRING(80) / WSTRING(80) for the string types).
|
||||
private static int StrideOf(TwinCATDataType t) => t switch
|
||||
{
|
||||
TwinCATDataType.String => 81,
|
||||
TwinCATDataType.WString => 162,
|
||||
_ => TwinCATTypeGuard.DeclaredByteSize(t),
|
||||
};
|
||||
|
||||
private static async Task<bool> DriverEncodesAsync(string tagConfig, string nodeType, int count)
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
using var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
RawTags = [new RawTagEntry("Arr", tagConfig, WriteIdempotent: false, DeviceName: Host)],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
}, "twincat-parity", new FakeTwinCATClientFactory { Customise = () => fake });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", ClrArray(nodeType, count))], CancellationToken.None);
|
||||
if (r[0].StatusCode != TwinCATStatusMapper.Good || fake.ArrayWriteLog.Count != 1) return false;
|
||||
|
||||
var (_, type, elements) = fake.ArrayWriteLog[0];
|
||||
if (elements.Count != count) return false;
|
||||
try
|
||||
{
|
||||
TwinCATArrayCodec.Encode(type, elements, new TwinCATArrayLayout(count, StrideOf(type)), utf8: false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is NotSupportedException or InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static TheoryData<TwinCATDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<TwinCATDataType, string>();
|
||||
foreach (var t in Enum.GetValues<TwinCATDataType>())
|
||||
foreach (var n in NodeTypes)
|
||||
data.Add(t, n);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every element type × every node DataType: 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(TwinCATDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"symbolPath":"MAIN.Arr","dataType":"{{dataType}}","isArray":true,"arrayLength":3}""";
|
||||
|
||||
if (dataType == TwinCATDataType.String && nodeType == "DateTime")
|
||||
{
|
||||
// The one CULTURE-dependent pair: a DateTime element reaches a STRING as Convert.ToString(DateTime), and
|
||||
// whether its strict Windows-1252 encode accepts that text depends on the host culture (ICU's en-US puts a
|
||||
// U+202F before "PM"; NLS a plain space). The table keeps it read-only — the conservative direction.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBeFalse();
|
||||
return;
|
||||
}
|
||||
|
||||
var driver = await DriverEncodesAsync(json, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, json).ShouldBe(driver, $"{dataType} on a {nodeType} node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes beyond the element type, both directions: a BOOL bit-in-word path (a scalar bit write, which an array
|
||||
/// value cannot feed), an undeclared length (a scalar tag), an element-offset path into a multi-dimensional
|
||||
/// array, a TwinCAT 2 global, and their positive controls.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"symbolPath":"MAIN.Flags","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Bool","isArray":true,"arrayLength":3}""", "Boolean")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Word.3","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Grid[1,0]","dataType":"Int","isArray":true,"arrayLength":3}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":".GlobalArr","dataType":"Real","isArray":true,"arrayLength":3}""", "Float")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","dataType":"Int","isArray":true,"arrayLength":0}""", "Int16")]
|
||||
[InlineData("""{"symbolPath":"MAIN.Arr","isArray":true,"arrayLength":3}""", "Int32")] // dataType ⇒ DInt
|
||||
public async Task Table_agrees_with_the_write_path_for_each_shape(string tagConfig, string nodeType)
|
||||
{
|
||||
var driver = await DriverEncodesAsync(tagConfig, nodeType, 3);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, nodeType, tagConfig).ShouldBe(driver, tagConfig);
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// <list type="bullet">
|
||||
/// <item>An S7 Int16 array (S7 encodes non-Bool arrays as one block) is writable on BOTH its raw node and
|
||||
/// its UNS mirror, and a whole-array write round-trips.</item>
|
||||
/// <item>An AbCip DInt array (AbCip has a scalar-only write encoder) stays read-only even though it is
|
||||
/// authored <c>ReadWrite</c>: the write is <c>BadNotWritable</c> at the node and never reaches the
|
||||
/// gateway — the "never advertise a write the driver will refuse" rule.</item>
|
||||
/// <item>An AbCip BOOL array (bit-packed DWORDs: AbCip's array encoder refuses it, though it encodes the atomic
|
||||
/// numeric arrays since #737) stays read-only even though it is authored <c>ReadWrite</c>: the write is
|
||||
/// <c>BadNotWritable</c> at the node and never reaches the gateway — the "never advertise a write the
|
||||
/// driver will refuse" rule.</item>
|
||||
/// <item>A device-refused array write is reverted on whichever NodeId it was written through.</item>
|
||||
/// <item>A wrong-length array and an IndexRange (partial) write are refused inline, before dispatch, and
|
||||
/// leave the node holding its prior array.</item>
|
||||
@@ -110,7 +111,8 @@ public sealed class ArrayWriteWireTests
|
||||
{
|
||||
TagId = "t-counts", DeviceId = "dev-2", Name = "Counts", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}",
|
||||
// A BOOL array: the AbCip array encoder has no encoding for it (a DInt array is writable since #737).
|
||||
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
|
||||
+71
-4
@@ -271,8 +271,8 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
}
|
||||
|
||||
/// <summary>Gitea #610: <c>ArrayWritable</c> is derived per OWNING DRIVER at compose time, and the UNS mirror
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; the identical shape on
|
||||
/// an AbCip driver (scalar-only write encoder) is not; a scalar is never flagged.</summary>
|
||||
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; an AbCip BOOL array (bit-packed
|
||||
/// DWORDs, which AbCip's array encoder refuses) is not; a scalar is never flagged.</summary>
|
||||
[Fact]
|
||||
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
|
||||
{
|
||||
@@ -286,7 +286,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
{
|
||||
new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array },
|
||||
new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}" },
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
@@ -303,7 +303,7 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
|
||||
c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
|
||||
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no BOOL-array encoder
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
||||
|
||||
@@ -347,4 +347,71 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse();
|
||||
c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #737 / #738 / #739 (review 07 U10 / U9): AbCip, AbLegacy and TwinCAT encode whole-array writes, so an
|
||||
/// authored-<c>ReadWrite</c> array tag on each now materialises WRITABLE — raw node and UNS mirror alike — with
|
||||
/// the ScadaBridge-shaped Int16 node type. An array already deployed read-only turns writable on the next deploy
|
||||
/// through <c>ArrayWritable</c>, which rides the surgical whitelist (pinned in the classifier tests). A shape
|
||||
/// each encoder refuses (an AbCip BOOL array, an AbLegacy Bit array, an AbLegacy Int array on an F file, a
|
||||
/// TwinCAT BOOL bit-in-word path) stays read-only on the SAME drivers — the negative control.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AbCip_AbLegacy_and_TwinCAT_arrays_materialise_writable_and_refused_shapes_stay_read_only()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab", DriverType = "AbCip", DriverConfig = "{}" };
|
||||
var slc = new DriverInstance { DriverInstanceId = "drv-slc", ClusterId = "c1", RawFolderId = "f", Name = "Slc", DriverType = "AbLegacy", DriverConfig = "{}" };
|
||||
var tc = new DriverInstance { DriverInstanceId = "drv-tc", ClusterId = "c1", RawFolderId = "f", Name = "Tc", DriverType = "TwinCAT", DriverConfig = "{}" };
|
||||
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-ab", Name = "plc", DeviceConfig = "{}" };
|
||||
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-slc", Name = "slc", DeviceConfig = "{\"plcFamily\":\"Slc500\"}" };
|
||||
var d3 = new Device { DeviceId = "d3", DriverInstanceId = "drv-tc", Name = "ads", DeviceConfig = "{}" };
|
||||
static Tag Arr(string id, string device, string dataType, string tagConfig) => new()
|
||||
{
|
||||
TagId = id, DeviceId = device, Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig,
|
||||
};
|
||||
var tags = new[]
|
||||
{
|
||||
Arr("abInts", "d1", "Int16", "{\"tagPath\":\"Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("abBools", "d1", "Boolean", "{\"tagPath\":\"Bools\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcInts", "d2", "Int16", "{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcBits", "d2", "Boolean", "{\"address\":\"B3:0\",\"dataType\":\"Bit\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("slcWrongFile", "d2", "Int16", "{\"address\":\"F8:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("tcInts", "d3", "Int16", "{\"symbolPath\":\"MAIN.Ints\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("tcBit", "d3", "Boolean", "{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[]
|
||||
{
|
||||
new UnsTagReference { UnsTagReferenceId = "r-ab", EquipmentId = "e", TagId = "abInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-slc", EquipmentId = "e", TagId = "slcInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-tc", EquipmentId = "e", TagId = "tcInts" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-bits", EquipmentId = "e", TagId = "slcBits" },
|
||||
};
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { ab, slc, tc }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2, d3 });
|
||||
|
||||
foreach (var writable in new[] { "abInts", "slcInts", "tcInts" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == writable);
|
||||
plan.ArrayWritable.ShouldBeTrue(writable);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
|
||||
}
|
||||
foreach (var refused in new[] { "abBools", "slcBits", "slcWrongFile", "tcBit" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == refused);
|
||||
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
|
||||
plan.ArrayWritable.ShouldBeFalse(refused);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
|
||||
}
|
||||
|
||||
foreach (var id in new[] { "r-ab", "r-slc", "r-tc" })
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user