Merge commit 'd1f24cf6' into remed/lane-K

This commit is contained in:
Joseph Doherty
2026-10-22 17:13:56 -04:00
105 changed files with 9559 additions and 831 deletions
@@ -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&lt;int&gt;</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.
@@ -34,6 +34,24 @@ public sealed class AlarmConditionStateSchemaTests
((long)cmd.ExecuteScalar()!).ShouldBe(1);
}
/// <summary>Apply also creates the append-only comment table, keyed by alarm id + ordinal.</summary>
[Fact]
public void Apply_creates_the_comment_table_with_a_composite_key()
{
using var connection = OpenInMemory();
AlarmConditionStateSchema.Apply(connection);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT name FROM pragma_table_info(@Name) WHERE pk > 0 ORDER BY pk";
cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.CommentsTable);
var pk = new List<string>();
using (var reader = cmd.ExecuteReader())
while (reader.Read()) pk.Add(reader.GetString(0));
pk.ShouldBe(["scripted_alarm_id", "seq"]);
}
/// <summary>Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).</summary>
[Fact]
public void Apply_is_idempotent()
@@ -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&lt;int&gt;</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"));
}
}
}
@@ -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)]
@@ -0,0 +1,162 @@
using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Comms-loss → recovery against the real pymodbus <c>standard</c> fixture, with a loopback
/// <see cref="TcpRelay"/> in front of it so the device can disappear and come back without touching the
/// shared container. Covers the review 07 High findings P3 (transport self-heal) and H2 (deadband
/// recovery publish).
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "Standard")]
public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
{
private const uint StatusGood = 0u;
private void SkipUnlessStandardLive()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
if (profile is not null && !string.Equals(profile, "standard", StringComparison.OrdinalIgnoreCase))
Assert.Skip($"MODBUS_SIM_PROFILE={profile} — these tests need the standard profile (HR[0..31] = address).");
}
private ModbusDriverOptions Options(TcpRelay relay, params ModbusTagDefinition[] tags) => new()
{
Host = "127.0.0.1",
Port = relay.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
RawTags = ModbusRawTags.Entries(tags),
Probe = new ModbusProbeOptions { Enabled = false },
};
private static async Task<T> WaitFor<T>(Func<Task<T>> probe, Func<T, bool> done, TimeSpan budget)
{
var sw = Stopwatch.StartNew();
T last;
do
{
last = await probe();
if (done(last)) return last;
await Task.Delay(200, TestContext.Current.CancellationToken);
} while (sw.Elapsed < budget);
return last;
}
/// <summary>
/// P3: after the device drops (two failed calls in a row, socket torn down, reconnect refused) the
/// transport must reconnect ON ITS OWN once the device is back — before this fix every later call
/// failed fast with "Transport not connected" until the 90 s comms-loss watchdog re-initialised the
/// driver.
/// </summary>
[Fact]
public async Task Driver_reads_again_after_the_device_drops_and_returns_without_reinit()
{
SkipUnlessStandardLive();
await using var relay = new TcpRelay(sim.Host, sim.Port);
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16, Writable: false);
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-p3");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var good = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
good[0].StatusCode.ShouldBe(StatusGood);
good[0].Value.ShouldBe((ushort)5);
relay.Cut();
for (var i = 0; i < 2; i++)
{
var bad = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
bad[0].StatusCode.ShouldNotBe(StatusGood);
}
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
relay.Restore();
var recovered = await WaitFor(
() => driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken),
r => r[0].StatusCode == StatusGood,
TimeSpan.FromSeconds(20));
recovered[0].StatusCode.ShouldBe(StatusGood, "the transport must reconnect by itself once the device is back");
recovered[0].Value.ShouldBe((ushort)5);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// U1 (FC23 opt-in, DisableFC23 kill-switch): a multi-register write on the opted-in FC23 write + read-back path
/// round-trips against the real server — whether it implements FC23 or answers Illegal Function and
/// is moved to FC16 — and so does the default FC16 path. Uses the standard profile's scratch HR[200..209].
/// </summary>
[Theory]
[InlineData(true, 200)]
[InlineData(false, 202)]
public async Task Multi_register_write_round_trips_with_and_without_FC23(bool useFc23, int address)
{
SkipUnlessStandardLive();
var tag = new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, (ushort)address, ModbusDataType.Float32);
var opts = new ModbusDriverOptions
{
Host = sim.Host, Port = sim.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), UseFC23ForMultiRegisterWrites = useFc23,
RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(opts, "modbus-u1");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var value = useFc23 ? 6.5f : 12.25f;
for (var i = 0; i < 2; i++) // second write exercises the latched path when the server lacks FC23
{
var w = await driver.WriteAsync([new WriteRequest("F", value + i)], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(StatusGood);
var r = await driver.ReadAsync(["F"], TestContext.Current.CancellationToken);
r[0].Value.ShouldBe(value + i);
}
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// H2: a deadbanded tag whose device returns holding the SAME value must publish the Good recovery.
/// HR[5] is a constant 5 in the standard profile, so the recovery value is identical by construction.
/// Positive control: the Bad publish during the outage is observed first.
/// </summary>
[Fact]
public async Task Deadbanded_subscription_publishes_the_Good_recovery_with_an_identical_value()
{
SkipUnlessStandardLive();
await using var relay = new TcpRelay(sim.Host, sim.Port);
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16,
Writable: false, Deadband: 10.0);
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-h2");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var published = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
driver.OnDataChange += (_, e) => published.Enqueue(e.Snapshot);
var handle = await driver.SubscribeAsync(["HR5"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
try
{
(await WaitFor(() => Task.FromResult(published.ToArray()),
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(10)))
.ShouldContain(s => s.StatusCode == StatusGood, "initial Good publish");
relay.Cut();
(await WaitFor(() => Task.FromResult(published.ToArray()),
p => p.Any(s => s.StatusCode != StatusGood), TimeSpan.FromSeconds(20)))
.ShouldContain(s => s.StatusCode != StatusGood, "the outage must publish Bad (positive control)");
var badCount = published.Count;
relay.Restore();
var after = await WaitFor(() => Task.FromResult(published.Skip(badCount).ToArray()),
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(30));
var recovery = after.FirstOrDefault(s => s.StatusCode == StatusGood);
recovery.ShouldNotBeNull("the Good recovery with the same value must not be swallowed by the deadband");
recovery.Value.ShouldBe((ushort)5);
}
finally
{
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
}
}
}
@@ -0,0 +1,101 @@
using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Loopback TCP relay in front of the real Modbus fixture, so a test can make the device "disappear"
/// (<see cref="Cut"/>: every relayed connection is closed and new connects are refused) and "come back"
/// (<see cref="Restore"/>: listen again on the SAME port) without touching the shared container.
/// </summary>
internal sealed class TcpRelay : IAsyncDisposable
{
private readonly string _upstreamHost;
private readonly int _upstreamPort;
private readonly object _lock = new();
private readonly List<TcpClient> _live = [];
private TcpListener? _listener;
private CancellationTokenSource _stop = new();
private int _accepted;
public TcpRelay(string upstreamHost, int upstreamPort)
{
_upstreamHost = upstreamHost;
_upstreamPort = upstreamPort;
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
Restore();
}
/// <summary>Gets the loopback port clients dial.</summary>
public int Port { get; }
/// <summary>Gets the number of client connections the relay has accepted.</summary>
public int AcceptedConnections => Volatile.Read(ref _accepted);
/// <summary>Starts (or resumes) relaying on <see cref="Port"/>.</summary>
public void Restore()
{
_stop = new CancellationTokenSource();
var listener = new TcpListener(IPAddress.Loopback, Port);
listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
listener.Start();
_listener = listener;
var token = _stop.Token;
_ = Task.Run(() => AcceptLoopAsync(listener, token));
}
/// <summary>Closes every relayed connection and refuses new ones until <see cref="Restore"/>.</summary>
public void Cut()
{
try { _stop.Cancel(); } catch { }
try { _listener?.Stop(); } catch { }
_listener = null;
lock (_lock)
{
foreach (var c in _live) { try { c.Close(); } catch { } }
_live.Clear();
}
}
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
TcpClient downstream;
try { downstream = await listener.AcceptTcpClientAsync(token); }
catch { return; }
Interlocked.Increment(ref _accepted);
_ = Task.Run(() => RelayAsync(downstream, token));
}
}
private async Task RelayAsync(TcpClient downstream, CancellationToken token)
{
var upstream = new TcpClient(AddressFamily.InterNetwork);
lock (_lock) { _live.Add(downstream); _live.Add(upstream); }
try
{
var addr = (await Dns.GetHostAddressesAsync(_upstreamHost, token))
.First(a => a.AddressFamily == AddressFamily.InterNetwork);
await upstream.ConnectAsync(addr, _upstreamPort, token);
var a = downstream.GetStream();
var b = upstream.GetStream();
await Task.WhenAny(a.CopyToAsync(b, token), b.CopyToAsync(a, token));
}
catch { /* cut or upstream gone */ }
finally
{
try { downstream.Close(); } catch { }
try { upstream.Close(); } catch { }
}
}
public ValueTask DisposeAsync()
{
Cut();
return ValueTask.CompletedTask;
}
}
@@ -25,10 +25,41 @@ public sealed class ModbusArrayWriteSupportParityTests
return data;
}
/// <summary>
/// Shapes where the driver has moved ahead of the server-side table (review 07 P6 / U3, Modbus lane) and
/// <see cref="DriverArrayWriteSupport"/> — owned outside the Modbus lane — has not followed yet
/// (follow-up F1): the tag factory now REFUSES a Coils tag with a non-Bool type (so the driver serves no
/// such tag), and the driver now encodes String[] and BitInRegister[] holding-register writes.
/// <see cref="Pending_F1_divergences_still_exist"/> fails the moment the table follows, so this list
/// is removed with it rather than left to rot.
/// </summary>
private static bool PendingF1(ModbusRegion region, ModbusDataType dataType) =>
(region == ModbusRegion.Coils && dataType != ModbusDataType.Bool)
|| (region == ModbusRegion.HoldingRegisters && dataType is ModbusDataType.String or ModbusDataType.BitInRegister);
public static TheoryData<ModbusRegion, ModbusDataType> PendingShapes()
{
var data = new TheoryData<ModbusRegion, ModbusDataType>();
foreach (var (region, dt) in Shapes().Where(s => PendingF1(s.Region, s.DataType))) data.Add(region, dt);
return data;
}
[Theory]
[MemberData(nameof(PendingShapes))]
public void Pending_F1_divergences_still_exist(ModbusRegion region, ModbusDataType dataType)
{
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
var driverAccepts = region == ModbusRegion.HoldingRegisters; // String[] / BitInRegister[] encode; mistyped Coils are refused
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldNotBe(driverAccepts,
"F1 landed — DriverArrayWriteSupport now agrees with the driver for this shape; delete it from PendingF1");
}
[Theory]
[MemberData(nameof(AllShapes))]
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
{
if (PendingF1(region, dataType)) return; // covered by Pending_F1_divergences_still_exist
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
var fake = new ModbusDriverTests.FakeTransport();
var drv = new ModbusDriver(
@@ -0,0 +1,181 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Review 07 U2: block-read coalescing is ON by default (gap 8), and it covers coils, discrete inputs,
/// <c>BitInRegister</c> and <c>String</c> tags — 16 bit tags on one status word used to cost 16 reads of
/// the same register, and 64 adjacent coils 64 FC01s.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingDefaultsTests
{
private static async Task<(ModbusDriver, ScriptedTransport)> StartAsync(ModbusDriverOptions opts)
{
var t = new ScriptedTransport();
var d = new ModbusDriver(opts, "m1", _ => t);
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (d, t);
}
private static ModbusDriverOptions Opts(IEnumerable<ModbusTagDefinition> tags, bool disable = false) => new()
{
Host = "f", RawTags = ModbusRawTags.Entries(tags), DisableReadCoalescing = disable,
Probe = new ModbusProbeOptions { Enabled = false },
};
[Fact]
public void Default_options_coalesce_with_a_gap_of_8()
{
new ModbusDriverOptions().MaxReadGap.ShouldBe((ushort)8);
new ModbusDriverOptions().DisableReadCoalescing.ShouldBeFalse();
ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h"}""").MaxReadGap.ShouldBe((ushort)8);
}
[Fact]
public async Task Default_configured_holding_registers_are_read_in_one_PDU()
{
var tags = Enumerable.Range(0, 10).Select(i =>
new ModbusTagDefinition($"HR{i}", ModbusRegion.HoldingRegisters, (ushort)(100 + i * 3), ModbusDataType.Int16)).ToArray();
var (drv, t) = await StartAsync(Opts(tags));
await using var _ = drv;
for (var i = 0; i < 10; i++) t.Bank.HoldingRegisters[100 + i * 3] = (ushort)(i + 1);
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
t.Requests(0x03).Count.ShouldBe(1);
r.Select(s => (short)s.Value!).ShouldBe(Enumerable.Range(1, 10).Select(i => (short)i));
}
/// <summary>
/// Fix round 1 (coordinator ruling 2): an explicitly authored maxReadGap 0 — what the pre-#741 form
/// persisted on every driver — keeps its old meaning, NO coalescing; the gap-8 default applies only to an
/// absent key (positive control).
/// </summary>
[Theory]
[InlineData("""{"host":"h","maxReadGap":0,"disableFC23":false}""", 2)]
[InlineData("""{"host":"h"}""", 1)]
public async Task A_persisted_maxReadGap_0_keeps_coalescing_off(string legacyConfig, int expectedReads)
{
var parsed = ModbusDriverFactoryExtensions.ParseOptions("m1", legacyConfig);
var tags = new[]
{
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
};
var (drv, t) = await StartAsync(new ModbusDriverOptions
{
Host = "f", MaxReadGap = parsed.MaxReadGap, DisableReadCoalescing = parsed.DisableReadCoalescing,
RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
});
await using var _ = drv;
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
t.Requests(0x03).Count.ShouldBe(expectedReads);
}
[Fact]
public async Task DisableReadCoalescing_reads_every_tag_alone()
{
// Positive control for the default-on test: the off switch really is per-tag.
var tags = new[]
{
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
};
var (drv, t) = await StartAsync(Opts(tags, disable: true));
await using var _ = drv;
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
t.Requests(0x03).Count.ShouldBe(2);
}
[Theory]
[InlineData(ModbusRegion.Coils, (byte)0x01)]
[InlineData(ModbusRegion.DiscreteInputs, (byte)0x02)]
public async Task Adjacent_bit_tags_fold_into_one_bit_read(ModbusRegion region, byte fc)
{
var tags = Enumerable.Range(0, 64).Select(i =>
new ModbusTagDefinition($"B{i}", region, (ushort)(10 + i), ModbusDataType.Bool)).ToArray();
var (drv, t) = await StartAsync(Opts(tags));
await using var _ = drv;
var bank = region == ModbusRegion.Coils ? t.Bank.Coils : t.Bank.DiscreteInputs;
for (var i = 0; i < 64; i++) bank[10 + i] = i % 3 == 0;
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
t.Requests(fc).ShouldBe([(fc, (ushort)10, (ushort)64)]);
for (var i = 0; i < 64; i++) r[i].Value.ShouldBe(i % 3 == 0, $"bit {i}");
}
[Fact]
public async Task Bit_tags_on_one_status_word_fold_into_one_register_read()
{
var tags = Enumerable.Range(0, 16).Select(b =>
new ModbusTagDefinition($"S{b}", ModbusRegion.HoldingRegisters, 40, ModbusDataType.BitInRegister, BitIndex: (byte)b)).ToArray();
var (drv, t) = await StartAsync(Opts(tags));
await using var _ = drv;
t.Bank.HoldingRegisters[40] = 0b1010_0000_0000_0101;
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)40, (ushort)1)]);
for (var b = 0; b < 16; b++) r[b].Value.ShouldBe(((0b1010_0000_0000_0101 >> b) & 1) == 1, $"bit {b}");
}
[Fact]
public async Task A_string_tag_coalesces_with_its_neighbours()
{
var tags = new[]
{
new ModbusTagDefinition("Num", ModbusRegion.HoldingRegisters, 50, ModbusDataType.Int16),
new ModbusTagDefinition("Str", ModbusRegion.HoldingRegisters, 51, ModbusDataType.String, StringLength: 4),
new ModbusTagDefinition("After", ModbusRegion.HoldingRegisters, 53, ModbusDataType.UInt16),
};
var (drv, t) = await StartAsync(Opts(tags));
await using var _ = drv;
t.Bank.HoldingRegisters[50] = 7;
t.Bank.HoldingRegisters[51] = ('A' << 8) | 'B';
t.Bank.HoldingRegisters[52] = ('C' << 8) | 'D';
t.Bank.HoldingRegisters[53] = 9;
var r = await drv.ReadAsync(["Num", "Str", "After"], TestContext.Current.CancellationToken);
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)50, (ushort)4)]);
r[0].Value.ShouldBe((short)7);
r[1].Value.ShouldBe("ABCD");
r[2].Value.ShouldBe((ushort)9);
}
[Fact]
public async Task A_refused_coil_range_is_prohibited_and_reprobed_with_FC01()
{
var tags = Enumerable.Range(0, 4).Select(i =>
new ModbusTagDefinition($"C{i}", ModbusRegion.Coils, (ushort)(20 + i), ModbusDataType.Bool)).ToArray();
var opts = new ModbusDriverOptions
{
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
AutoProhibitReprobeInterval = TimeSpan.FromHours(1),
};
var (drv, t) = await StartAsync(opts);
await using var _ = drv;
var refuseMerged = true;
t.Handler = (_, pdu, _) =>
{
var qty = (pdu[3] << 8) | pdu[4];
return pdu[0] == 0x01 && qty > 1 && refuseMerged
? Task.FromException<byte[]>(new ModbusException(0x01, 0x02, "protected"))
: null;
};
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
r.ShouldAllBe(s => s.StatusCode == 0u, "the per-tag fallback still reads every coil");
drv.AutoProhibitedRangeCount.ShouldBe(1);
refuseMerged = false;
while (t.Sent.TryDequeue(out var _1)) { }
await drv.RunReprobeOnceForTestAsync(TestContext.Current.CancellationToken);
t.Sent.ShouldAllBe(s => s.Pdu[0] == 0x01, "a coil range is re-probed with FC01, never a register read");
drv.AutoProhibitedRangeCount.ShouldBe(0);
}
}
@@ -42,9 +42,9 @@ public sealed class ModbusCoalescingTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Verifies that MaxReadGap=0 defaults to per-tag reads without coalescing.</summary>
/// <summary>MaxReadGap=0 means no coalescing (its historical meaning, kept for persisted configs).</summary>
[Fact]
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
public async Task MaxReadGap_Zero_Means_No_Coalescing()
{
var fake = new CountingTransport();
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
@@ -56,7 +56,7 @@ public sealed class ModbusCoalescingTests
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
// With coalescing off, expect 2 separate FC03 reads.
// MaxReadGap=0 is coalescing OFF: expect 2 separate FC03 reads.
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2);
}
@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Review 07 H2: the deadband baseline is keyed on (value, status). A Good-after-Bad recovery carrying
/// the IDENTICAL value must publish — the value-only dedup left the node Bad until the value drifted.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusDeadbandRecoveryTests
{
private const uint Good = 0u;
private const uint Uncertain = 0x40000000u;
private const uint BadComm = 0x80050000u;
private static async Task<ModbusDriver> NewDriverAsync()
{
var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0);
var opts = new ModbusDriverOptions
{
Host = "f", RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
};
var drv = new ModbusDriver(opts, "m1", _ => new ModbusDriverTests.FakeTransport());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
return drv;
}
private static DataValueSnapshot Snap(object? v, uint status) => new(v, status, DateTime.UtcNow, DateTime.UtcNow);
[Fact]
public async Task Good_after_Bad_with_the_same_value_publishes()
{
await using var drv = await NewDriverAsync();
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue(); // baseline
drv.ShouldPublish("Temp", Snap(null, BadComm)).ShouldBeTrue(); // comms drop
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("the recovery publish must not be swallowed");
// Positive control: the deadband is still active after the recovery re-seeded the baseline.
drv.ShouldPublish("Temp", Snap((short)11, Good)).ShouldBeFalse();
drv.ShouldPublish("Temp", Snap((short)16, Good)).ShouldBeTrue();
}
[Fact]
public async Task A_status_change_with_the_same_value_publishes()
{
await using var drv = await NewDriverAsync();
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue();
drv.ShouldPublish("Temp", Snap((short)10, Uncertain)).ShouldBeTrue("a quality change must publish");
drv.ShouldPublish("Temp", Snap((short)12, Uncertain)).ShouldBeFalse("within deadband at the same quality");
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("back to Good must publish");
}
}
@@ -46,6 +46,7 @@ public sealed class ModbusDriverTests
0x06 => Task.FromResult(WriteSingleReg(pdu)),
0x0F => Task.FromResult(WriteMultipleCoils(pdu)),
0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
0x17 => Task.FromResult(ReadWriteMultipleRegs(pdu)),
_ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
};
}
@@ -102,6 +103,26 @@ public sealed class ModbusDriverTests
return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
}
// FC23 Read/Write Multiple Registers: write first, then read back the read range.
private byte[] ReadWriteMultipleRegs(byte[] pdu)
{
var readAddr = (ushort)((pdu[1] << 8) | pdu[2]);
var readQty = (ushort)((pdu[3] << 8) | pdu[4]);
var writeAddr = (ushort)((pdu[5] << 8) | pdu[6]);
var writeQty = (ushort)((pdu[7] << 8) | pdu[8]);
for (var i = 0; i < writeQty; i++)
HoldingRegisters[writeAddr + i] = (ushort)((pdu[10 + i * 2] << 8) | pdu[11 + i * 2]);
var resp = new byte[2 + readQty * 2];
resp[0] = 0x17;
resp[1] = (byte)(readQty * 2);
for (var i = 0; i < readQty; i++)
{
resp[2 + i * 2] = (byte)(HoldingRegisters[readAddr + i] >> 8);
resp[3 + i * 2] = (byte)(HoldingRegisters[readAddr + i] & 0xFF);
}
return resp;
}
private byte[] WriteMultipleCoils(byte[] pdu)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
@@ -0,0 +1,252 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Review 07 U1: <c>Family</c>, <c>MelsecSubFamily</c> and <c>DisableFC23</c> were authored in the
/// AdminUI and consumed by nothing. A tag's <c>addressString</c> now resolves through
/// <see cref="ModbusAddressParser"/> with the driver's family; <c>DisableFC23</c> gates the FC23
/// write-and-read-back path for multi-register writes. Also pins that the AdminUI form's own
/// (TimeSpan-shaped) config keys reach the driver.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusFamilyWiringTests
{
// ---------------- addressString through the family-aware parser ----------------
[Fact]
public void A_MELSEC_D_register_resolves_only_under_the_MELSEC_family()
{
const string json = """{"addressString":"D100"}""";
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
def.Address.ShouldBe((ushort)100);
// Positive control: the same text means nothing to a Generic (Modicon) driver.
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
}
[Fact]
public void A_DL205_V_memory_address_resolves_octal_under_DL205()
{
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"V2000"}""", "T", ModbusFamily.DL205, MelsecFamily.Q_L_iQR, out var def)
.ShouldBeTrue();
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
def.Address.ShouldBe((ushort)0x400); // V2000 octal = 1024
}
[Fact]
public void A_string_only_blob_carries_type_order_and_count_and_keeps_the_other_keys()
{
ModbusTagDefinitionFactory.FromTagConfig(
"""{"addressString":"40011:F:CDAB:4","writable":false,"unitId":3}""",
"T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
def.Address.ShouldBe((ushort)10);
def.DataType.ShouldBe(ModbusDataType.Float32);
def.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
def.ArrayCount.ShouldBe(4);
def.Writable.ShouldBeFalse();
def.UnitId.ShouldBe((byte)3);
}
/// <summary>
/// Fix round 1 (review m2): persisted region/address are authoritative over an addressString, so a
/// later Family / MelsecSubFamily change can never silently remap a live tag the editor resolved. (Since
/// fix round 3 the keys win by PRESENCE; the marker in this blob is display state only.)
/// </summary>
[Fact]
public void Persisted_resolved_keys_win_over_the_address_string_so_a_family_change_cannot_remap()
{
// The editor resolved D100 under MELSEC, persisted HR 100 and MARKED it; the driver is later switched.
const string json = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""";
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
{
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)100, $"family {family}");
}
}
/// <summary>
/// Fix round 3 (review r2 N2): the persisted-shape matrix. Every shape that carries region + address loads
/// from them under every family — including the pre-#741 "keys + addressString, no marker" row, which the
/// old driver served from its keys and must keep serving so. Only a string-only blob (no keys) resolves
/// the string.
/// </summary>
[Theory]
[InlineData("keys only", """{"region":"InputRegisters","address":5}""", ModbusRegion.InputRegisters, 5)]
[InlineData("pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", ModbusRegion.InputRegisters, 5)]
[InlineData("pre-lane stamped HR/0 + string", """{"region":"HoldingRegisters","address":0,"dataType":"Int16","addressString":"D100"}""", ModbusRegion.HoldingRegisters, 0)]
[InlineData("string only", """{"addressString":"40011:F"}""", ModbusRegion.HoldingRegisters, 10)]
[InlineData("string + marker + keys (editor-pinned)", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusRegion.HoldingRegisters, 100)]
[InlineData("string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", ModbusRegion.HoldingRegisters, 10)]
public void Every_persisted_shape_resolves_to_its_address_under_every_family(
string shape, string json, ModbusRegion region, int address)
{
foreach (var (family, sub) in new[]
{
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR), (ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR), (ModbusFamily.MELSEC, MelsecFamily.F_iQF),
})
{
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def).ShouldBeTrue($"{shape} / {family}");
def.Region.ShouldBe(region, $"{shape} / {family}");
def.Address.ShouldBe((ushort)address, $"{shape} / {family}");
}
}
[Fact]
public void A_string_only_blob_no_family_parses_is_refused_not_served_as_HR0()
{
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"D100"}""", "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _)
.ShouldBeFalse();
}
[Fact]
public void An_unparseable_address_string_rejects_the_tag_and_the_deploy_gate_says_why()
{
const string json = """{"addressString":"ZZ9"}""";
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("addressString"));
// The family-unaware gate accepts what SOME family resolves (it must never be stricter than the driver).
ModbusTagDefinitionFactory.Inspect("""{"addressString":"D100"}""").ShouldBeEmpty();
}
[Fact]
public async Task The_driver_resolves_address_strings_with_its_configured_family()
{
var fake = new ScriptedTransport();
fake.Bank.HoldingRegisters[100] = 1234;
var opts = new ModbusDriverOptions
{
Host = "f", Family = ModbusFamily.MELSEC, Probe = new ModbusProbeOptions { Enabled = false },
RawTags = [new RawTagEntry("D100", """{"addressString":"D100"}""", false)],
};
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["D100"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe((short)1234);
}
[Fact]
public void Family_and_MelsecSubFamily_parse_from_the_driver_config()
{
var o = ModbusDriverFactoryExtensions.ParseOptions("m1",
"""{"host":"h","family":"MELSEC","melsecSubFamily":"F_iQF","disableFC23":true,"useFC23ForMultiRegisterWrites":true}""");
o.Family.ShouldBe(ModbusFamily.MELSEC);
o.MelsecSubFamily.ShouldBe(MelsecFamily.F_iQF);
o.DisableFC23.ShouldBeTrue();
o.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
}
// ---------------- FC23 is OPT-IN; DisableFC23 is the kill-switch ----------------
private static async Task<(ModbusDriver, ScriptedTransport)> StartWriteAsync(string driverConfigJson)
{
var t = new ScriptedTransport();
var opts = ModbusDriverFactoryExtensions.ParseOptions("m1", driverConfigJson) is var parsed
? new ModbusDriverOptions
{
Host = "f", DisableFC23 = parsed.DisableFC23,
UseFC23ForMultiRegisterWrites = parsed.UseFC23ForMultiRegisterWrites,
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Float32)]),
}
: null!;
var drv = new ModbusDriver(opts, "m1", _ => t);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (drv, t);
}
/// <summary>
/// Fix round 1 (coordinator ruling 1): a config without the opt-in key keeps the FC16 wire — including the
/// legacy shape every form-saved config carries ("disableFC23": false).
/// </summary>
[Theory]
[InlineData("""{"host":"h"}""")]
[InlineData("""{"host":"h","disableFC23":false,"maxReadGap":0,"useFC15ForSingleCoilWrites":false}""")]
public async Task Without_the_opt_in_multi_register_writes_stay_on_FC16(string legacyConfig)
{
var (drv, t) = await StartWriteAsync(legacyConfig);
await using var _ = drv;
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
}
[Fact]
public async Task Opted_in_multi_register_writes_use_FC23_write_and_read_back()
{
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
await using var _ = drv;
var w = await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(0u);
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17]);
(await drv.ReadAsync(["F"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(1.5f);
}
[Fact]
public async Task DisableFC23_overrides_the_opt_in()
{
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true,"disableFC23":true}""");
await using var _ = drv;
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
}
[Fact]
public async Task An_opted_in_device_without_FC23_falls_back_to_FC16_once_and_stays_on_it()
{
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
await using var _ = drv;
t.Handler = (_, pdu, _) => pdu[0] == 0x17
? Task.FromException<byte[]>(new ModbusException(0x17, 0x01, "illegal function"))
: null;
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
(await drv.WriteAsync([new WriteRequest("F", 2.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17, (byte)0x10, (byte)0x10]);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---------------- the AdminUI form's own keys reach the driver ----------------
[Fact]
public void The_AdminUI_form_shape_of_the_timing_keys_is_honoured()
{
// The form serialises ModbusDriverOptions itself (camelCase, TimeSpan strings), not the *Ms DTO keys.
var formShape = JsonSerializer.Serialize(new ModbusDriverOptions
{
Host = "h",
Timeout = TimeSpan.FromSeconds(7),
IdleDisconnectTimeout = TimeSpan.FromSeconds(90),
AutoProhibitReprobeInterval = TimeSpan.FromSeconds(45),
Probe = new ModbusProbeOptions { Interval = TimeSpan.FromSeconds(11), Timeout = TimeSpan.FromSeconds(3) },
KeepAlive = new ModbusKeepAliveOptions { Time = TimeSpan.FromSeconds(60), Interval = TimeSpan.FromSeconds(20) },
Reconnect = new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(2), MaxDelay = TimeSpan.FromSeconds(40) },
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", formShape);
o.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
o.IdleDisconnectTimeout.ShouldBe(TimeSpan.FromSeconds(90));
o.AutoProhibitReprobeInterval.ShouldBe(TimeSpan.FromSeconds(45));
o.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(11));
o.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
o.KeepAlive.Time.ShouldBe(TimeSpan.FromSeconds(60));
o.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(20));
o.Reconnect.InitialDelay.ShouldBe(TimeSpan.FromSeconds(2));
o.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromSeconds(40));
}
[Fact]
public void The_millisecond_keys_still_win_when_both_are_present()
{
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h","timeoutMs":1500,"timeout":"00:00:07"}""");
o.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
}
}
@@ -0,0 +1,402 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// The driver health contract (review 07 H4 + H7, and the lifecycle/fail-fast rules every driver lane
/// must meet): a device REFUSAL is per-tag Bad with no Degraded and proves the device answered; a comms
/// fault degrades and preserves LastSuccessfulRead; health is one write per batch; a comms timeout on
/// one unit fails the rest of that unit's batch fast; writes map transport failures to
/// BadCommunicationError and value problems to BadOutOfRange / BadTypeMismatch.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusHealthContractTests
{
private const uint Good = 0u;
private const uint BadOutOfRange = 0x803C0000u;
private const uint BadDeviceFailure = 0x808B0000u;
private const uint BadCommunicationError = 0x80050000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private static ModbusDriverOptions Opts(params ModbusTagDefinition[] tags) => new()
{
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
};
private static ModbusTagDefinition Hr(string name, ushort addr, byte? unit = null, ModbusDataType type = ModbusDataType.Int16) =>
new(name, ModbusRegion.HoldingRegisters, addr, type, UnitId: unit);
private static Task<byte[]> Refuse(byte code, byte[] pdu) =>
Task.FromException<byte[]>(new ModbusException(pdu[0], code, $"exception {code}"));
private static ushort Addr(byte[] pdu) => (ushort)((pdu[1] << 8) | pdu[2]);
private static async Task<(ModbusDriver Driver, ScriptedTransport Transport)> StartAsync(
ModbusDriverOptions opts, ILogger<ModbusDriver>? logger = null)
{
var t = new ScriptedTransport();
var d = new ModbusDriver(opts, "m1", _ => t, logger);
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (d, t);
}
// ---------------- H4: refusals ----------------
[Fact]
public async Task All_tags_refused_keeps_Healthy_and_advances_LastSuccessfulRead()
{
var (drv, t) = await StartAsync(Opts(Hr("A", 10), Hr("B", 11)));
await using var _ = drv;
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
var before = drv.GetHealth().LastSuccessfulRead;
var r = await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
r.ShouldAllBe(s => s.StatusCode == BadOutOfRange);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy, "a refusal means the device answered — no Degraded, no watchdog fodder");
h.LastSuccessfulRead.ShouldNotBeNull();
h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before!.Value);
}
[Fact]
public async Task A_refusal_after_a_comms_fault_restores_Healthy()
{
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
await using var _ = drv;
t.Handler = (_, _, _) => Task.FromException<byte[]>(new IOException("socket dropped"));
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value);
}
[Theory]
[InlineData(0x04)]
[InlineData(0x0B)]
public async Task Non_refusal_exception_codes_still_degrade(byte code)
{
// Positive control for the refusal tests: a server-failure / gateway code is NOT a refusal.
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
await using var _ = drv;
t.Handler = (_, pdu, _) => Refuse(code, pdu);
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Mixed_refused_and_good_batch_is_Healthy_regardless_of_tag_order(bool refusedFirst)
{
var (drv, t) = await StartAsync(Opts(Hr("Refused", 10), Hr("Fine", 50)));
await using var _ = drv;
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Refuse(0x02, pdu) : null;
string[] refs = refusedFirst ? ["Refused", "Fine"] : ["Fine", "Refused"];
var r = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
r.Count(s => s.StatusCode == Good).ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task A_refused_tag_warns_once_not_once_per_poll()
{
var logger = new ListLogger();
var (drv, t) = await StartAsync(Opts(Hr("A", 10)), logger);
await using var _ = drv;
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
for (var i = 0; i < 3; i++) await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
logger.Warnings.Count(m => m.Contains("refused") && m.Contains("A")).ShouldBe(1);
}
// ---------------- fail fast per unit / endpoint ----------------
[Fact]
public async Task A_comms_timeout_on_one_unit_fails_the_rest_of_that_units_batch_fast()
{
var (drv, t) = await StartAsync(Opts(Hr("U2a", 10, 2), Hr("U2b", 11, 2), Hr("U2c", 12, 2), Hr("U1", 13, 1)));
await using var _ = drv;
t.Bank.HoldingRegisters[13] = 42;
t.Handler = (unit, _, _) => unit == 2
? Task.FromException<byte[]>(new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout, "timeout"))
: null;
var r = await drv.ReadAsync(["U2a", "U2b", "U2c", "U1"], TestContext.Current.CancellationToken);
t.SendsTo(2).ShouldBe(1, "one timeout per unit per batch, not one per tag");
r.Take(3).ShouldAllBe(s => s.StatusCode == BadCommunicationError);
// Positive control: the healthy unit in the same batch is still read.
r[3].StatusCode.ShouldBe(Good);
r[3].Value.ShouldBe((short)42);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Fact]
public async Task An_unreachable_endpoint_fails_the_whole_batch_fast()
{
var (drv, t) = await StartAsync(Opts(Hr("A", 10, 1), Hr("B", 11, 2), Hr("C", 12, 3)));
await using var _ = drv;
t.Handler = (_, _, _) => Task.FromException<byte[]>(new ModbusTransportUnavailableException("throttled"));
var r = await drv.ReadAsync(["A", "B", "C"], TestContext.Current.CancellationToken);
t.TotalSends.ShouldBe(1);
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
}
// ---------------- H7: write status mapping + write-side health ----------------
[Fact]
public async Task Write_transport_failure_is_BadCommunicationError_and_a_good_read_clears_it()
{
// Fix round 1 (review Important #3): ONE health writer for reads and writes. A single write blip used
// to latch Degraded until the next good WRITE — forever, in a read-mostly plant.
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
await using var _ = drv;
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
(await drv.ReadAsync(["A"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a good read proves the shared transport answered");
}
[Fact]
public async Task A_good_write_clears_a_read_side_comms_fault()
{
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
await using var _ = drv;
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
(await drv.WriteAsync([new WriteRequest("A", (short)2)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task A_malformed_reply_does_not_fail_the_rest_of_the_unit_fast()
{
// Review m3: a byte-count mismatch is a reply that ARRIVED — only a timeout / desync / IO fails the unit fast.
var (drv, t) = await StartAsync(Opts(Hr("Bad", 10), Hr("Fine", 50)) is var o
? new ModbusDriverOptions { Host = o.Host, RawTags = o.RawTags, Probe = o.Probe, DisableReadCoalescing = true }
: null!);
await using var _ = drv;
t.Bank.HoldingRegisters[50] = 9;
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Task.FromResult(new byte[] { 0x03, 0x04, 0, 0 }) : null;
var r = await drv.ReadAsync(["Bad", "Fine"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(BadCommunicationError);
r[1].Value.ShouldBe((short)9, "the sibling on the same unit is still read");
}
[Fact]
public async Task DisposeAsync_leaves_health_Unknown()
{
// Review m1: Dispose bumps the generation (dropping a concurrent shutdown's write), so it must write its own.
var (drv, _) = await StartAsync(Opts(Hr("A", 10)));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
[Fact]
public async Task A_write_refusal_counts_as_answered_and_clears_a_write_side_fault()
{
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
await using var _ = drv;
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Refuse(0x03, pdu) : null;
var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(BadOutOfRange);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Write_value_problems_map_to_OutOfRange_and_TypeMismatch_without_touching_health()
{
var arr = new ModbusTagDefinition("Arr", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Int16, ArrayCount: 3);
var (drv, t) = await StartAsync(Opts(Hr("Bcd", 10, type: ModbusDataType.Bcd16), Hr("I16", 11), arr));
await using var _ = drv;
var w = await drv.WriteAsync([
new WriteRequest("Bcd", 12345), // > 4 BCD digits
new WriteRequest("I16", 70000), // > Int16
new WriteRequest("I16", "not a number"),
new WriteRequest("Arr", new short[] { 1, 2 }), // length mismatch
new WriteRequest("Arr", new int[] { 1, 2, 3 }), // CLR list of another element type converts (ScadaBridge shape)
], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(BadOutOfRange);
w[1].StatusCode.ShouldBe(BadOutOfRange);
w[2].StatusCode.ShouldBe(BadTypeMismatch);
w[3].StatusCode.ShouldBe(BadOutOfRange);
w[4].StatusCode.ShouldBe(Good);
t.Bank.HoldingRegisters[22].ShouldBe((ushort)3);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task A_failed_read_invalidates_the_WriteOnChangeOnly_cache()
{
// Value-only suppression is the write-side twin of the #527 dedup trap: after a comms fault the
// device's register is unknown (it may have rebooted), so re-asserting the same setpoint must reach it.
var opts = new ModbusDriverOptions
{
Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]), WriteOnChangeOnly = true,
Probe = new ModbusProbeOptions { Enabled = false },
};
var (drv, t) = await StartAsync(opts);
await using var _ = drv;
int Fc06() => t.Sent.Count(s => s.Pdu[0] == 0x06);
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
Fc06().ShouldBe(1, "positive control: an identical re-write is suppressed while nothing failed");
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
t.Handler = null;
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
Fc06().ShouldBe(2, "after a failed read the device value is unknown; the write must go to the wire");
}
// ---------------- lifecycle generation ----------------
[Fact]
public async Task A_superseded_initialize_never_publishes_over_the_shutdown_that_overtook_it()
{
var t = new ScriptedTransport { ConnectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) };
var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => t) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await t.ConnectEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
// The init is wedged in its connect; shutdown waits out the gate timeout and tears down anyway.
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
t.ConnectGate.SetResult(); // the abandoned connect finally completes
await Should.ThrowAsync<OperationCanceledException>(() => init);
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the superseded init must not publish Healthy");
t.DisposeCount.ShouldBe(1, "the superseded init releases the transport it built");
await Should.ThrowAsync<InvalidOperationException>(() => drv.ReadAsync(["A"], TestContext.Current.CancellationToken));
}
[Fact]
public async Task A_read_that_straddles_a_reinitialize_does_not_write_health_about_the_new_transport()
{
var first = new ScriptedTransport();
var second = new ScriptedTransport();
var release = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
first.Handler = (_, _, _) => { entered.TrySetResult(); return release.Task; };
var calls = 0;
var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => Interlocked.Increment(ref calls) == 1 ? first : second);
await using var _ = drv;
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var read = drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // re-init onto `second`
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
release.SetException(new IOException("old socket died")); // the straddling read now fails
(await read)[0].StatusCode.ShouldBe(BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a read on the superseded transport must not degrade the new one");
// Positive control: a failure on the CURRENT transport still degrades.
second.Handler = (_, _, _) => Task.FromException<byte[]>(new IOException("new socket died"));
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Fact]
public async Task Reinitialize_with_a_new_tag_set_drops_tags_the_new_config_no_longer_has()
{
var t = new ScriptedTransport();
var drv = new ModbusDriver(Opts(Hr("Old", 10)), "m1", _ => t);
await using var _ = drv;
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
(await drv.ReadAsync(["Old"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
var config = JsonSerializer.Serialize(new
{
host = "f",
probe = new { enabled = false },
rawTags = ModbusRawTags.Entries([Hr("New", 11)]),
});
await drv.InitializeAsync(config, TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Old", "New"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(BadNodeIdUnknown, "a re-init publishes a fresh table, it does not merge into the old one");
r[1].StatusCode.ShouldBe(Good);
}
// ---------------- complete teardown ----------------
[Fact]
public async Task Shutdown_joins_the_probe_loop_so_no_host_transition_fires_after_it_returns()
{
var t = new ScriptedTransport();
var probeBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var probes = 0;
t.Handler = async (_, pdu, ct) =>
{
if (Interlocked.Increment(ref probes) == 1) return await t.Bank.SendAsync(1, pdu, ct); // first probe: Running
probeBlocked.TrySetResult();
try { await Task.Delay(Timeout.Infinite, ct); }
catch (OperationCanceledException) { }
throw new ObjectDisposedException("transport"); // a teardown-time failure, NOT an OCE
};
var opts = new ModbusDriverOptions
{
Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]),
Probe = new ModbusProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(30) },
};
var drv = new ModbusDriver(opts, "m1", _ => t);
var running = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var afterShutdown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var shutdownReturned = false;
drv.OnHostStatusChanged += (_, e) =>
{
if (e.NewState == HostState.Running) running.TrySetResult();
if (Volatile.Read(ref shutdownReturned)) afterShutdown.TrySetResult();
};
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await running.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // positive control
await probeBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
Volatile.Write(ref shutdownReturned, true);
await Should.ThrowAsync<TimeoutException>(
() => afterShutdown.Task.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
}
private sealed class ListLogger : ILogger<ModbusDriver>
{
public System.Collections.Concurrent.ConcurrentQueue<string> Messages { get; } = new();
public IEnumerable<string> Warnings => Messages.Where(m => m.StartsWith("Warning:", StringComparison.Ordinal));
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Messages.Enqueue($"{logLevel}: {formatter(state, exception)}");
}
}
@@ -92,7 +92,7 @@ public sealed class ModbusLoggerInjectionTests
// Scan 1 — coalesced read fails. Expect exactly one warning.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("auto-prohibited")).ToList();
warnings.Count.ShouldBe(1);
warnings[0].Message.ShouldContain("drv-logged");
warnings[0].Message.ShouldContain("Start=100");
@@ -102,7 +102,13 @@ public sealed class ModbusLoggerInjectionTests
// the prohibition and skips the merge; even if it didn't, the de-dupe in
// RecordAutoProhibition would suppress).
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1, "re-fire of same range stays silent");
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("auto-prohibited"))
.ShouldBe(1, "re-fire of same range stays silent");
// The protected register's per-tag fallback is a device refusal: one Warning for that tag across
// both scans (once-per-tag), and none for its readable neighbours.
var refusals = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("refused")).ToList();
refusals.Count.ShouldBe(1);
refusals[0].Message.ShouldContain("T102");
await drv.ShutdownAsync(CancellationToken.None);
}
@@ -0,0 +1,103 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Review 07 P6: region/type compatibility is enforced at the tag factory (and so at the deploy gate
/// and the AdminUI parse consult). A <c>HoldingRegisters</c> + <c>Bool</c> tag used to throw out of the
/// coalescing planner and kill the whole subscription's poll cycle; <c>Coils</c> + <c>Int16</c> silently
/// returned a bool into an Int32 node.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusRegionTypeValidationTests
{
[Theory]
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"Bool"}""")]
[InlineData("""{"region":"InputRegisters","address":1,"dataType":"Bool"}""")]
public void A_register_region_with_Bool_is_rejected(string json)
{
// That pair never read (RegisterCount threw), so refusing it breaks nothing that worked.
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("register"));
}
/// <summary>
/// Fix round 1 (review Important #2): the pre-#741 editor defaulted dataType to Int16 and persisted it for
/// coil / DI tags, and the old driver ignored it for bit regions — those tags worked and must keep loading.
/// A bit region with any type is coerced to Bool and draws no deploy warning (a warning can be promoted to
/// a blocking error by TagConfigValidationMode.Error, which would refuse an existing config).
/// </summary>
[Theory]
[InlineData("""{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""")]
[InlineData("""{"region":"DiscreteInputs","address":1,"dataType":"Float32"}""")]
public void A_legacy_bit_region_tag_with_a_register_type_still_loads_as_Bool(string legacyJson)
{
ModbusTagDefinitionFactory.FromTagConfig(legacyJson, "T", out var def).ShouldBeTrue();
def.DataType.ShouldBe(ModbusDataType.Bool);
ModbusTagDefinitionFactory.Inspect(legacyJson).ShouldBeEmpty();
}
[Fact]
public async Task A_legacy_Int16_coil_tag_reads_and_writes_the_coil()
{
var fake = new ModbusDriverTests.FakeTransport();
fake.Coils[3] = true;
var opts = new ModbusDriverOptions
{
Host = "f", Probe = new ModbusProbeOptions { Enabled = false },
RawTags = [new RawTagEntry("Run", """{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""", false)],
};
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
(await drv.ReadAsync(["Run"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(true);
(await drv.WriteAsync([new WriteRequest("Run", false)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
fake.Coils[3].ShouldBeFalse();
}
[Theory]
[InlineData("""{"region":"Coils","address":1,"dataType":"Bool"}""", ModbusDataType.Bool)]
[InlineData("""{"region":"Coils","address":1}""", ModbusDataType.Bool)]
[InlineData("""{"region":"DiscreteInputs","address":1}""", ModbusDataType.Bool)]
[InlineData("""{"region":"InputRegisters","address":1,"dataType":"BitInRegister","bitIndex":3}""", ModbusDataType.BitInRegister)]
[InlineData("""{"region":"HoldingRegisters","address":1}""", ModbusDataType.Int16)]
public void Compatible_region_and_type_is_accepted(string json, ModbusDataType expected)
{
// Positive control for the rejection theory, including the absent-dataType default per region.
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
def.DataType.ShouldBe(expected);
ModbusTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
}
[Fact]
public async Task A_mis_typed_tag_no_longer_kills_the_coalesced_poll_of_its_neighbours()
{
var fake = new ModbusDriverTests.FakeTransport();
fake.HoldingRegisters[100] = 7;
fake.HoldingRegisters[102] = 9;
var good1 = new ModbusTagDefinition("G1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var good2 = new ModbusTagDefinition("G2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var opts = new ModbusDriverOptions
{
Host = "f",
MaxReadGap = 5,
RawTags =
[
ModbusRawTags.Entry(good1),
new RawTagEntry("Bad", """{"region":"HoldingRegisters","address":101,"dataType":"Bool"}""", false),
ModbusRawTags.Entry(good2),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["G1", "Bad", "G2"], TestContext.Current.CancellationToken);
r[0].Value.ShouldBe((short)7);
r[1].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown — skipped at init, reported
r[2].Value.ShouldBe((short)9);
drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "Bad");
}
}
@@ -25,6 +25,17 @@ public sealed class ModbusRtuFramingTests
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
}
/// <summary>FC23 (Read/Write Multiple) replies like a read: byte count + data. It used to be sized as a
/// fixed 4-byte write echo and desync.</summary>
[Fact]
public async Task ReadResponse_FC23_returns_bare_pdu()
{
var body = new byte[] { 0x01, 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B };
await using var s = Canned(ModbusCrc.Append(body));
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B });
}
[Fact]
public async Task ReadResponse_exception_frame_throws_ModbusException()
{
@@ -0,0 +1,82 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Review 07 U3: <c>String[]</c> and <c>BitInRegister[]</c> tags read fine; their writes used to fail
/// <c>BadInternalError</c> (no element width for String, <c>Convert.ToBoolean(bool[])</c> for the bit
/// array). Both now encode <c>count</c> elements at the tag's element width; a length mismatch is
/// <c>BadOutOfRange</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusStringAndBitArrayWriteTests
{
private static async Task<(ModbusDriver, ScriptedTransport)> StartAsync(params ModbusTagDefinition[] tags)
{
var t = new ScriptedTransport();
var d = new ModbusDriver(new ModbusDriverOptions
{
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
}, "m1", _ => t);
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
return (d, t);
}
[Fact]
public async Task String_array_write_encodes_each_element_at_its_register_width()
{
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 10, ModbusDataType.String,
StringLength: 3, ArrayCount: 2); // 2 registers per element
var (drv, t) = await StartAsync(tag);
await using var _ = drv;
var w = await drv.WriteAsync([new WriteRequest("S", new[] { "AB", "XYZ" })], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(0u);
t.Bank.HoldingRegisters[10].ShouldBe((ushort)(('A' << 8) | 'B'));
t.Bank.HoldingRegisters[11].ShouldBe((ushort)0);
t.Bank.HoldingRegisters[12].ShouldBe((ushort)(('X' << 8) | 'Y'));
t.Bank.HoldingRegisters[13].ShouldBe((ushort)('Z' << 8));
// Round trip through the read path.
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(new[] { "AB", "XYZ" });
}
[Fact]
public async Task BitInRegister_array_write_sets_only_the_addressed_bit_of_each_register()
{
var tag = new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister,
BitIndex: 4, ArrayCount: 3);
var (drv, t) = await StartAsync(tag);
await using var _ = drv;
t.Bank.HoldingRegisters[30] = 0xFFFF;
t.Bank.HoldingRegisters[31] = 0x0001;
t.Bank.HoldingRegisters[32] = 0x8000;
var w = await drv.WriteAsync([new WriteRequest("B", new object[] { false, true, 1 })], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(0u);
t.Bank.HoldingRegisters[30].ShouldBe((ushort)0xFFEF, "bit 4 cleared, neighbours kept");
t.Bank.HoldingRegisters[31].ShouldBe((ushort)0x0011);
t.Bank.HoldingRegisters[32].ShouldBe((ushort)0x8010);
(await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(new[] { false, true, true });
}
[Theory]
[InlineData("S")]
[InlineData("B")]
public async Task A_length_mismatch_is_BadOutOfRange_and_sends_nothing(string tagName)
{
var (drv, t) = await StartAsync(
new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 10, ModbusDataType.String, StringLength: 2, ArrayCount: 2),
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 1, ArrayCount: 3));
await using var _ = drv;
object value = tagName == "S" ? new[] { "only one" } : new[] { true };
var w = await drv.WriteAsync([new WriteRequest(tagName, value)], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(0x803C0000u);
t.Sent.Where(s => s.Pdu[0] is 0x06 or 0x10).ShouldBeEmpty();
}
}
@@ -0,0 +1,64 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #741 fix round 4 (review r3 N3): <see cref="ModbusTagAddressResolution"/> is the one authority for which part
/// of a Modbus TagConfig names the address. The cross-consumer agreement matrix lives in AdminUI.Tests
/// (<c>ModbusAddressAuthorityAgreementTests</c>); these pin the resolver's own classification and refusals.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusTagAddressResolutionTests
{
[Theory]
[InlineData("""{"region":"InputRegisters","address":5}""", ModbusAddressShape.Keys)]
[InlineData("""{"region":"HoldingRegisters","address":0,"addressString":"D100"}""", ModbusAddressShape.Keys)]
[InlineData("""{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusAddressShape.Keys)]
[InlineData("""{"addressString":"40011:F"}""", ModbusAddressShape.StringOnly)]
[InlineData("""{"addressString":"40011:F","addressResolved":true}""", ModbusAddressShape.StringOnly)]
[InlineData("""{"addressString":"40011:F","region":"InputRegisters"}""", ModbusAddressShape.StringOnly)]
[InlineData("""{"addressString":"ZZ9"}""", ModbusAddressShape.StringOnly)]
[InlineData("""{"addressString":5}""", ModbusAddressShape.InvalidAddressString)]
[InlineData("""{"addressString":" "}""", ModbusAddressShape.NoAddress)]
[InlineData("""{"addressString":null}""", ModbusAddressShape.NoAddress)]
[InlineData("""{}""", ModbusAddressShape.NoAddress)]
[InlineData("""[1]""", ModbusAddressShape.NoAddress)]
[InlineData("""not json""", ModbusAddressShape.NoAddress)]
public void Classify_decides_shape_by_key_presence_never_by_the_marker(string json, ModbusAddressShape shape)
=> ModbusTagAddressResolution.Classify(json).ShouldBe(shape);
[Fact]
public void A_string_only_blob_is_refused_with_a_reason_when_the_family_cannot_parse_it()
{
var r = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
r.IsResolved.ShouldBeFalse();
r.Shape.ShouldBe(ModbusAddressShape.StringOnly);
r.Refusal.ShouldNotBeNull().ShouldContain("D100");
var melsec = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR);
melsec.Address.ShouldNotBeNull().Address.ShouldBe((ushort)100);
}
[Fact]
public void Keys_are_read_strictly_and_a_bit_region_is_coerced_to_Bool()
{
ModbusTagAddressResolution.Resolve("""{"region":"Holding","address":1}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
.IsResolved.ShouldBeFalse();
ModbusTagAddressResolution.Resolve("""{"region":"HoldingRegisters","address":70000}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
.IsResolved.ShouldBeFalse();
var coil = ModbusTagAddressResolution.Resolve("""{"region":"Coils","address":3,"dataType":"Int16"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
coil.Address.ShouldNotBeNull().DataType.ShouldBe(ModbusDataType.Bool);
}
[Fact]
public void AddressStringDescribes_matches_only_the_fields_some_family_parses_it_to()
{
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16,
ModbusByteOrder.BigEndian, 0, 0).ShouldBeTrue();
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16,
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
ModbusTagAddressResolution.AddressStringDescribes("ZZ9", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16,
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
}
}
@@ -0,0 +1,139 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Transport self-heal (review 07 P3), single in-gate connect attempt + failed-client disposal (L3)
/// and the response-PDU length check (H9).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusTransportSelfHealTests
{
private static readonly byte[] ReadHr0 = [0x03, 0x00, 0x00, 0x00, 0x01];
/// <summary>
/// P3: two lost replies in a row leave the socket torn down. The NEXT call must reconnect on its own
/// instead of failing fast with "Transport not connected" until the 90 s comms-loss watchdog.
/// </summary>
[Fact]
public async Task After_two_consecutive_timeouts_the_next_call_reconnects()
{
await using var server = new ScriptedModbusTcpServer
{
Script = i => i < 2 ? ScriptedModbusTcpServer.Action.Stall : ScriptedModbusTcpServer.Action.Answer,
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(500));
await transport.ConnectAsync(TestContext.Current.CancellationToken);
// First call: stall → teardown → reconnect → resend → stall again → propagates.
await Should.ThrowAsync<ModbusTransportDesyncException>(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
// Second call: the socket is gone; the transport must dial again and succeed.
var resp = await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
resp[0].ShouldBe((byte)0x03);
server.AcceptedConnections.ShouldBe(3);
}
/// <summary>
/// P3 throttle: after a FAILED connect, calls inside the backoff window fail fast with
/// <see cref="ModbusTransportUnavailableException"/> and make no connect attempt; once the window has
/// passed the next call dials again. Positive control: the device is back before the throttled call,
/// so an un-throttled transport would have reconnected.
/// </summary>
[Fact]
public async Task Failed_connect_is_throttled_then_retried_after_the_window()
{
await using var server = new ScriptedModbusTcpServer();
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
var now = 1_000_000L;
transport.Lifecycle.NowMs = () => now;
await transport.ConnectAsync(TestContext.Current.CancellationToken);
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
server.Stop();
// Dead socket → EOF → teardown → ONE reconnect attempt → refused.
var first = await Should.ThrowAsync<Exception>(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ModbusSocketLifecycle.IsSocketLevelFailure(first).ShouldBeTrue();
server.Start();
var acceptedBefore = server.AcceptedConnections;
await Should.ThrowAsync<ModbusTransportUnavailableException>(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
server.AcceptedConnections.ShouldBe(acceptedBefore, "a throttled call must not dial");
now += 60_000; // past any window (MaxDelay default 30 s)
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
server.AcceptedConnections.ShouldBe(acceptedBefore + 1);
}
/// <summary>
/// L3: the reconnect never sleeps inside the transport gate. With a 30 s InitialDelay the old
/// <c>ConnectWithBackoffAsync</c> held the gate for 30 s before even dialling; now one attempt is made
/// and the call returns at once.
/// </summary>
[Fact]
public async Task Reconnect_after_drop_does_not_sleep_inside_the_gate()
{
await using var server = new ScriptedModbusTcpServer();
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2),
reconnect: new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(30) });
await transport.ConnectAsync(TestContext.Current.CancellationToken);
server.Stop();
var call = transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<Exception>(() => call.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
ex.ShouldNotBeOfType<TimeoutException>("the send must not wait out the 30 s backoff inside the gate");
}
/// <summary>H9: an MBAP frame carrying no PDU is a truncated-frame desync, not an IndexOutOfRangeException.</summary>
[Fact]
public async Task Empty_response_pdu_is_a_truncated_frame_desync()
{
await using var server = new ScriptedModbusTcpServer
{
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, []),
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
}
/// <summary>H9: an exception frame missing its exception code is a truncated-frame desync.</summary>
[Fact]
public async Task Exception_frame_without_code_is_a_truncated_frame_desync()
{
await using var server = new ScriptedModbusTcpServer
{
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83]),
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
}
/// <summary>Positive control for the two H9 tests: a well-formed exception frame still surfaces as a
/// <see cref="ModbusException"/> carrying its code.</summary>
[Fact]
public async Task Well_formed_exception_frame_still_surfaces_as_ModbusException()
{
await using var server = new ScriptedModbusTcpServer
{
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83, 0x02]),
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<ModbusException>(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ex.ExceptionCode.ShouldBe((byte)0x02);
}
}
@@ -0,0 +1,160 @@
using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Loopback Modbus-TCP stub whose reply to each request is scripted by the test, and whose listener can
/// be stopped (connection refused) and restarted on the SAME port. Used by the transport self-heal tests
/// that need a device which disappears and comes back.
/// </summary>
internal sealed class ScriptedModbusTcpServer : IAsyncDisposable
{
/// <summary>What the server does with one request.</summary>
public enum Action
{
/// <summary>Answer with an FC03-shaped response of zeroed registers.</summary>
Answer,
/// <summary>Read the request and never answer it (the client's per-op timeout fires).</summary>
Stall,
/// <summary>Close the socket without answering.</summary>
Drop,
}
private TcpListener? _listener;
private CancellationTokenSource _stop = new();
private int _requests;
private int _accepted;
/// <summary>Optional scripted reply. Receives the 0-based request index and the request PDU; returns
/// the raw bytes to write AFTER the MBAP TxId (i.e. the full ADU is built by the test), or null to fall
/// through to <see cref="Script"/>.</summary>
public Func<int, byte[], byte[], byte[]?>? RawReply { get; set; }
/// <summary>Per-request action; defaults to <see cref="Action.Answer"/>.</summary>
public Func<int, Action> Script { get; set; } = _ => Action.Answer;
/// <summary>Gets the port the server listens on (stable across <see cref="Stop"/>/<see cref="Start"/>).</summary>
public int Port { get; }
/// <summary>Gets the number of accepted TCP connections.</summary>
public int AcceptedConnections => Volatile.Read(ref _accepted);
/// <summary>Gets the number of requests received.</summary>
public int Requests => Volatile.Read(ref _requests);
public ScriptedModbusTcpServer()
{
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
Start();
}
/// <summary>(Re)starts listening on <see cref="Port"/>.</summary>
public void Start()
{
_stop = new CancellationTokenSource();
_listener = new TcpListener(IPAddress.Loopback, Port);
_listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_listener.Start();
var listener = _listener;
var token = _stop.Token;
_ = Task.Run(() => AcceptLoopAsync(listener, token));
}
/// <summary>Stops listening and closes every live connection — new connects are refused.</summary>
public void Stop()
{
try { _stop.Cancel(); } catch { }
try { _listener?.Stop(); } catch { }
_listener = null;
}
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
TcpClient client;
try { client = await listener.AcceptTcpClientAsync(token); }
catch { return; }
Interlocked.Increment(ref _accepted);
_ = Task.Run(() => ServeAsync(client, token));
}
}
private async Task ServeAsync(TcpClient client, CancellationToken token)
{
using var _ = client;
using var reg = token.Register(() => { try { client.Close(); } catch { } });
try
{
var stream = client.GetStream();
while (!token.IsCancellationRequested)
{
var header = new byte[7];
if (!await ReadExactly(stream, header, token)) return;
var len = (ushort)((header[4] << 8) | header[5]);
var pdu = new byte[len - 1];
if (!await ReadExactly(stream, pdu, token)) return;
var idx = Interlocked.Increment(ref _requests) - 1;
var raw = RawReply?.Invoke(idx, header, pdu);
if (raw is not null)
{
await stream.WriteAsync(raw, token);
await stream.FlushAsync(token);
continue;
}
switch (Script(idx))
{
case Action.Stall:
continue;
case Action.Drop:
client.Client.Shutdown(SocketShutdown.Both);
return;
}
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var respPdu = new byte[2 + qty * 2];
respPdu[0] = pdu[0];
respPdu[1] = (byte)(qty * 2);
await stream.WriteAsync(Adu(header, respPdu), token);
await stream.FlushAsync(token);
}
}
catch { /* connection torn down */ }
}
/// <summary>Builds a reply ADU echoing the request's TxId/unit around <paramref name="respPdu"/>.</summary>
public static byte[] Adu(byte[] requestHeader, byte[] respPdu)
{
var respLen = (ushort)(1 + respPdu.Length);
var adu = new byte[7 + respPdu.Length];
adu[0] = requestHeader[0]; adu[1] = requestHeader[1];
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
adu[6] = requestHeader[6];
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
return adu;
}
private static async Task<bool> ReadExactly(Stream s, byte[] buf, CancellationToken token)
{
var read = 0;
while (read < buf.Length)
{
var n = await s.ReadAsync(buf.AsMemory(read), token);
if (n == 0) return false;
read += n;
}
return true;
}
public ValueTask DisposeAsync()
{
Stop();
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,59 @@
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// In-memory transport whose every send is answered by a test-supplied handler; falls back to the
/// register/coil bank fake (<see cref="ModbusDriverTests.FakeTransport"/>) when the handler returns null.
/// Counts sends per unit and dispose calls; the connect can be blocked on a gate.
/// </summary>
internal sealed class ScriptedTransport : IModbusTransport
{
private readonly ConcurrentDictionary<byte, int> _sendsByUnit = new();
private int _disposeCount;
/// <summary>The bank answering un-scripted sends.</summary>
public ModbusDriverTests.FakeTransport Bank { get; } = new();
/// <summary>Per-send handler; return null to fall through to <see cref="Bank"/>.</summary>
public Func<byte, byte[], CancellationToken, Task<byte[]>?>? Handler { get; set; }
/// <summary>When set, <see cref="ConnectAsync"/> waits for it before completing.</summary>
public TaskCompletionSource? ConnectGate { get; set; }
/// <summary>Signalled when <see cref="ConnectAsync"/> is entered.</summary>
public TaskCompletionSource ConnectEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int DisposeCount => Volatile.Read(ref _disposeCount);
public int SendsTo(byte unit) => _sendsByUnit.TryGetValue(unit, out var n) ? n : 0;
public int TotalSends => _sendsByUnit.Values.Sum();
/// <summary>Every PDU sent, in order.</summary>
public ConcurrentQueue<(byte Unit, byte[] Pdu)> Sent { get; } = new();
/// <summary>The (function code, start, quantity) of every sent request with that function code.</summary>
public List<(byte Fc, ushort Start, ushort Qty)> Requests(byte fc) =>
[.. Sent.Where(s => s.Pdu[0] == fc).Select(s => (s.Pdu[0], (ushort)((s.Pdu[1] << 8) | s.Pdu[2]), (ushort)((s.Pdu[3] << 8) | s.Pdu[4])))];
public async Task ConnectAsync(CancellationToken ct)
{
ConnectEntered.TrySetResult();
if (ConnectGate is { } gate) await gate.Task.WaitAsync(ct);
}
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
_sendsByUnit.AddOrUpdate(unitId, 1, (_, n) => n + 1);
Sent.Enqueue((unitId, pdu));
var scripted = Handler?.Invoke(unitId, pdu, ct);
return scripted ?? Bank.SendAsync(unitId, pdu, ct);
}
public ValueTask DisposeAsync()
{
Interlocked.Increment(ref _disposeCount);
return ValueTask.CompletedTask;
}
}
@@ -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]
@@ -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)]));
}
@@ -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.
@@ -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);
}
}
@@ -30,6 +30,8 @@ public sealed class ModbusDriverPageFormSerializationTests
UseFC15ForSingleCoilWrites = true,
UseFC16ForSingleRegisterWrites = true,
DisableFC23 = true,
UseFC23ForMultiRegisterWrites = true,
DisableReadCoalescing = true,
WriteOnChangeOnly = true,
AutoReconnect = false,
Family = ModbusFamily.DL205,
@@ -72,6 +74,8 @@ public sealed class ModbusDriverPageFormSerializationTests
back.UseFC15ForSingleCoilWrites.ShouldBeTrue();
back.UseFC16ForSingleRegisterWrites.ShouldBeTrue();
back.DisableFC23.ShouldBeTrue();
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
back.DisableReadCoalescing.ShouldBeTrue();
back.WriteOnChangeOnly.ShouldBeTrue();
back.AutoReconnect.ShouldBeFalse();
back.Family.ShouldBe(ModbusFamily.DL205);
@@ -0,0 +1,230 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Review r3 N3 (#741, fix round 4): <b>one authority for a Modbus tag's address</b>. Every persisted
/// <c>TagConfig</c> shape goes through EVERY consumer that decides, displays or rewrites the address. They
/// must all agree with the driver, under every family:
/// <list type="bullet">
/// <item>the driver's tag reader (<c>ModbusTagDefinitionFactory.FromTagConfig</c> with the driver's
/// family, the exact call <c>ModbusDriver</c> makes at initialize);</item>
/// <item>the deploy gate (<c>EquipmentTagConfigInspector.Inspect</c>);</item>
/// <item>the editor model's load (what its fields claim), save (incl. a Writable toggle) and
/// <c>Validate</c>;</item>
/// <item>the CSV export → import round trip through the real <c>RawTagCsvMapper</c> (the export residual,
/// not the stored blob, is the import base).</item>
/// </list>
/// The expected verdict per family is pinned in the table, so the authority itself cannot drift either.
/// Three rounds converged on the rule one consumer at a time; this matrix is what makes a new divergence
/// fail in one place.
/// </summary>
public sealed class ModbusAddressAuthorityAgreementTests
{
private static readonly (ModbusFamily Family, MelsecFamily Sub, string Label)[] Families =
[
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR, "Generic"),
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR, "DL205"),
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, "MELSEC-Q"),
(ModbusFamily.MELSEC, MelsecFamily.F_iQF, "MELSEC-F"),
];
/// <summary>
/// Shape name, persisted blob, and the expected driver verdict per family in the order of
/// <see cref="Families"/> (<c>IR5</c> = InputRegisters[5], <c>-</c> = refused).
/// </summary>
public static TheoryData<string, string, string> Shapes => new()
{
{ "keys only", """{"region":"InputRegisters","address":5}""", "IR5|IR5|IR5|IR5" },
{ "pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", "IR5|IR5|IR5|IR5" },
{ "pre-lane stamped HR/0 + string",
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"addressString":"D100"}""",
"HR0|HR0|HR0|HR0" },
{ "string only", """{"addressString":"40011:F"}""", "HR10|HR10|HR10|HR10" },
{ "string only, family-native", """{"addressString":"D100"}""", "-|-|HR100|HR100" },
{ "string + marker + keys", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""", "HR100|HR100|HR100|HR100" },
{ "string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", "HR10|HR10|HR10|HR10" },
{ "string + marker, no keys, family-native", """{"addressString":"D100","addressResolved":true}""", "-|-|HR100|HR100" },
{ "malformed string", """{"addressString":"ZZ9"}""", "-|-|-|-" },
{ "malformed string + marker", """{"addressString":"ZZ9","addressResolved":true}""", "-|-|-|-" },
{ "malformed string + keys", """{"region":"InputRegisters","address":5,"addressString":"ZZ9"}""", "IR5|IR5|IR5|IR5" },
{ "non-string addressString", """{"addressString":5}""", "-|-|-|-" },
{ "string + one key", """{"addressString":"40011:F","region":"InputRegisters"}""", "HR10|HR10|HR10|HR10" },
{ "no address", """{"writable":true}""", "-|-|-|-" },
{ "bit region, legacy Int16", """{"region":"Coils","address":3,"dataType":"Int16"}""", "CO3|CO3|CO3|CO3" },
};
[Theory]
[MemberData(nameof(Shapes))]
public void Every_consumer_agrees_with_the_driver_on_every_persisted_shape(string shape, string blob, string expected)
{
var want = expected.Split('|');
var anyServed = want.Any(v => v != "-");
// 1. The driver (the authority) — pinned, so the authority cannot drift unnoticed either.
for (var i = 0; i < Families.Length; i++)
{
Verdict(blob, i).ShouldBe(want[i], $"{shape}: driver / {Families[i].Label}");
}
// 2. The deploy gate: clean exactly when some family's driver serves the tag.
(EquipmentTagConfigInspector.Inspect(DriverTypeNames.Modbus, blob).Count == 0)
.ShouldBe(anyServed, $"{shape}: deploy gate accepts ⇔ some driver serves it");
// 3. Editor load: when the model presents its fields as the address, they ARE the driver's address.
var loaded = ModbusTagConfigModel.FromJson(blob);
if (!loaded.IsStringOnly)
{
for (var i = 0; i < Families.Length; i++)
{
if (want[i] == "-") continue;
FieldsVerdict(loaded).ShouldBe(want[i], $"{shape}: editor fields / {Families[i].Label}");
}
}
// 4. Editor save, untouched and after a Writable toggle (the round trip that remapped N1 / N3).
var untouched = ModbusTagConfigModel.FromJson(blob).ToJson();
var toggled = ModbusTagConfigModel.FromJson(blob);
toggled.Writable = false;
var toggledJson = toggled.ToJson();
for (var i = 0; i < Families.Length; i++)
{
Verdict(untouched, i).ShouldBe(want[i], $"{shape}: editor save / {Families[i].Label}");
Verdict(toggledJson, i).ShouldBe(want[i], $"{shape}: editor save after Writable toggle / {Families[i].Label}");
}
// 5. Validate: accepts exactly what some family's driver serves.
(ModbusTagConfigModel.FromJson(blob).Validate() is null).ShouldBe(anyServed, $"{shape}: Validate");
// 6. CSV export → import through the production mapper (the residual is the import base).
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
var parsed = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
parsed.FatalError.ShouldBeNull();
var row = parsed.Rows.ShouldHaveSingleItem();
if (!row.Ok)
{
anyServed.ShouldBeFalse($"{shape}: CSV import refused a tag the driver serves ({row.Error})");
}
else
{
for (var i = 0; i < Families.Length; i++)
{
Verdict(row.ImportRow!.Tag.TagConfig, i).ShouldBe(want[i], $"{shape}: CSV export → import / {Families[i].Label}");
}
}
}
private static string Verdict(string json, int familyIndex)
{
var (family, sub, _) = Families[familyIndex];
return ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def)
? Short(def.Region) + def.Address
: "-";
}
private static string FieldsVerdict(ModbusTagConfigModel m) => Short(m.Region) + m.Address;
private static string Short(ModbusRegion r) => r switch
{
ModbusRegion.HoldingRegisters => "HR",
ModbusRegion.InputRegisters => "IR",
ModbusRegion.Coils => "CO",
ModbusRegion.DiscreteInputs => "DI",
_ => r.ToString(),
};
// ------------------------------------------------------------------ CSV metadata (review r3 m1 / m3)
private static RawCsvReviewRow RoundTrip(string blob, Func<string, string>? editCsv = null)
{
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
if (editCsv is not null) csv = editCsv(csv);
return RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv).Rows.ShouldHaveSingleItem();
}
/// <summary>m1: an UNCHANGED export → import of a pinned row keeps its address string and marker.</summary>
[Fact]
public void An_unchanged_csv_round_trip_of_a_pinned_row_keeps_its_address_string_and_marker()
{
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
var row = RoundTrip(pinned);
row.Ok.ShouldBeTrue(row.Error);
var json = row.ImportRow!.Tag.TagConfig;
json.ShouldContain("\"addressString\":\"D100\"");
json.ShouldContain("\"addressResolved\":true");
row.TypedOverrodeFallback.ShouldBeEmpty("nothing was overridden");
ModbusTagConfigModel.FromJson(json).IsPinned.ShouldBeTrue();
}
/// <summary>m1 positive control: an address cell that CONTRADICTS the string drops it, and the grid says so.</summary>
[Fact]
public void A_csv_address_cell_that_contradicts_the_string_drops_it_and_the_review_grid_reports_it()
{
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
// Edit the exported Address cell 100 → 101.
var row = RoundTrip(pinned, csv => csv.Replace(",HoldingRegisters,100,", ",HoldingRegisters,101,", StringComparison.Ordinal));
row.Ok.ShouldBeTrue(row.Error);
var json = row.ImportRow!.Tag.TagConfig;
json.ShouldNotContain("addressString");
json.ShouldNotContain("addressResolved");
row.TypedOverrodeFallback.ShouldNotBeEmpty("the dropped string is reported, not silent");
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)101);
}
/// <summary>m3: the n5 case on an UNMARKED string-only row — typed address cells are applied and drop the string.</summary>
[Fact]
public void A_typed_csv_address_cell_on_an_unmarked_string_only_row_is_applied_and_drops_the_string()
{
var map = CsvColumnMap.Resolve(DriverTypeNames.Modbus).ShouldNotBeNull();
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F"}""",
new Dictionary<string, string>(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
merged.ShouldNotContain("addressString");
foreach (var (family, sub, label) in Families)
{
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", family, sub, out var def).ShouldBeTrue(label);
def.Region.ShouldBe(ModbusRegion.InputRegisters, label);
def.Address.ShouldBe((ushort)7, label);
}
}
/// <summary>N3, by name: the marker without keys is string-only for the editor, its save and its CSV export.</summary>
[Fact]
public void A_marker_without_keys_is_string_only_and_never_saved_as_HR0()
{
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","addressResolved":true}""");
m.IsStringOnly.ShouldBeTrue();
m.IsPinned.ShouldBeFalse("a marker without keys pins nothing");
m.Writable = false;
var saved = m.ToJson();
saved.ShouldNotContain("\"address\":");
saved.ShouldNotContain("\"region\":");
CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.ReadTypedColumns(saved).ShouldNotContainKey("Address");
}
/// <summary>The editor never writes default keys it did not resolve: a Writable toggle on an address-less blob
/// does not author HoldingRegisters[0]; setting a field does.</summary>
[Fact]
public void An_address_less_blob_gains_keys_only_when_a_field_is_authored()
{
var m = ModbusTagConfigModel.FromJson("{}");
m.Writable = false;
m.ToJson().ShouldNotContain("\"region\":");
m.HasKeyAddress.ShouldBeFalse();
m.Address = 0; // an explicit authoring of HR[0] is honoured
m.HasKeyAddress.ShouldBeTrue();
ModbusTagDefinitionFactory.FromTagConfig(m.ToJson(), "T", out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)0);
}
}
@@ -22,6 +22,23 @@ public sealed class ModbusDriverFormModelTests
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Review r1 n2: the lane's new form keys survive FromOptions → ToOptions, and their defaults keep
/// the historical wire (FC16, coalescing on at gap 8 only when the key is absent).</summary>
[Fact]
public void Form_round_trips_the_FC23_opt_in_and_coalescing_keys()
{
var form = new ModbusDriverForm.FormModel { UseFC23ForMultiRegisterWrites = true, DisableFC23 = true, DisableReadCoalescing = true, MaxReadGap = 0 };
var back = ModbusDriverForm.FormModel.FromOptions(form.ToOptions());
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
back.DisableFC23.ShouldBeTrue();
back.DisableReadCoalescing.ShouldBeTrue();
back.MaxReadGap.ShouldBe(0);
var defaults = new ModbusDriverForm.FormModel().ToOptions();
defaults.UseFC23ForMultiRegisterWrites.ShouldBeFalse();
defaults.MaxReadGap.ShouldBe((ushort)8);
}
[Fact]
public void Round_trip_preserves_channel_fields_and_enum()
{
@@ -121,4 +121,190 @@ public sealed class ModbusTagConfigModelTests
[InlineData("""{"region":3,"address":1,"dataType":true}""")]
public void Validate_accepts_every_enum_value_the_driver_accepts(string json)
=> ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull();
/// <summary>Review 07 P6: switching region keeps the data type one the driver accepts for it.</summary>
[Theory]
[InlineData(ModbusDataType.Int16, ModbusRegion.Coils, ModbusDataType.Bool)]
[InlineData(ModbusDataType.Float32, ModbusRegion.DiscreteInputs, ModbusDataType.Bool)]
[InlineData(ModbusDataType.Bool, ModbusRegion.HoldingRegisters, ModbusDataType.Int16)]
[InlineData(ModbusDataType.Float32, ModbusRegion.InputRegisters, ModbusDataType.Float32)]
public void SetRegion_keeps_the_type_compatible(ModbusDataType from, ModbusRegion region, ModbusDataType expected)
{
var m = new ModbusTagConfigModel { DataType = from };
m.SetRegion(region);
m.Region.ShouldBe(region);
m.DataType.ShouldBe(expected);
ModbusTagConfigModel.FromJson(m.ToJson()).Validate().ShouldBeNull();
}
/// <summary>A register region with Bool (CSV / API) is refused at authoring, matching the driver.</summary>
[Fact]
public void Validate_refuses_a_register_region_with_Bool()
=> ModbusTagConfigModel.FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Bool"}""").Validate().ShouldNotBeNull();
/// <summary>
/// Fix round 1: the exact blob the pre-#741 editor persisted for a coil (dataType defaulted to Int16)
/// still validates, loads as Bool (what the driver polls), and re-saves as Bool.
/// </summary>
[Fact]
public void A_legacy_Int16_coil_blob_validates_and_loads_as_Bool()
{
const string legacy = """{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
var m = ModbusTagConfigModel.FromJson(legacy);
m.Validate().ShouldBeNull();
m.DataType.ShouldBe(ModbusDataType.Bool);
m.ToJson().ShouldContain("\"dataType\":\"Bool\"");
}
/// <summary>Review 07 U1: the editor resolves an address string with the DRIVER's family and persists
/// both the string and the resolved fields; the driver re-resolves the string with its family.</summary>
[Fact]
public void ResolveAddress_uses_the_driver_family_and_persists_string_and_resolved_fields()
{
var (family, sub) = ModbusTagConfigModel.ReadFamily("""{"Family":"MELSEC","melsecSubFamily":"Q_L_iQR"}""");
family.ShouldBe(ModbusFamily.MELSEC);
var m = ModbusTagConfigModel.FromJson("{}");
m.ResolveAddress("D100", family, sub).ShouldBeNull();
m.Region.ShouldBe(ModbusRegion.HoldingRegisters);
m.Address.ShouldBe(100);
var json = m.ToJson();
json.ShouldContain("\"addressString\":\"D100\"");
json.ShouldContain("\"address\":100");
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)100);
ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull("the family-unaware authoring gate accepts what the MELSEC driver serves");
// Positive control: under Generic the same text is an error, and nothing is persisted.
var g = ModbusTagConfigModel.FromJson("{}");
g.ResolveAddress("D100", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldNotBeNull();
g.AddressString.ShouldBeNull();
}
/// <summary>Fix round 2 (review r1 N1): a string-only tag through CSV import must still resolve its string
/// with the driver's family — never the HoldingRegisters/0 defaults the model loads it with.</summary>
[Fact]
public void Csv_import_of_a_string_only_tag_keeps_resolving_the_string()
{
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
var merged = map.ApplyTypedColumns("""{"addressString":"D100"}""",
new Dictionary<string, string>(StringComparer.Ordinal) { ["Writable"] = "false" });
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var melsec).ShouldBeTrue();
melsec.Address.ShouldBe((ushort)100, "not HR[0]");
melsec.Writable.ShouldBeFalse("the typed column still applied");
// Under another family the string is resolved by THAT family (here it does not parse), never HR[0].
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
// Export shows blank address cells for it, so an export → re-import cannot author an HR[0] address.
map.ReadTypedColumns(merged).ShouldNotContainKey("Region");
map.ReadTypedColumns(merged).ShouldNotContainKey("Address");
}
/// <summary>Fix round 2 (review r1 N1): an editor save (e.g. the Writable toggle) of a string-only tag.</summary>
[Fact]
public void Editor_save_of_a_string_only_tag_keeps_resolving_the_string()
{
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F"}""");
m.IsStringOnly.ShouldBeTrue();
m.Writable = false;
var json = m.ToJson();
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
def.Address.ShouldBe((ushort)10);
def.DataType.ShouldBe(ModbusDataType.Float32);
def.Writable.ShouldBeFalse();
json.ShouldNotContain("\"address\":");
}
/// <summary>Positive control for the two above: Resolve pins the fields (marker), and a family change then
/// cannot remap the tag.</summary>
[Fact]
public void A_resolved_tag_is_pinned_against_a_later_family_change()
{
var m = ModbusTagConfigModel.FromJson("{}");
m.ResolveAddress("D100", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR).ShouldBeNull();
var json = m.ToJson();
json.ShouldContain("\"addressResolved\":true");
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
{
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)100, $"family {family}");
}
}
private const string PreLaneRow = """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""";
/// <summary>Fix round 3 (review r2 N2): a pre-#741 row (keys + string, no marker) keeps resolving from its
/// keys through a model save and a CSV export → import, under every family.</summary>
[Fact]
public void A_pre_lane_keys_plus_string_row_keeps_its_keys_through_save_and_csv_round_trip()
{
var m = ModbusTagConfigModel.FromJson(PreLaneRow);
m.IsStringOnly.ShouldBeFalse();
m.HasLegacyAddressString.ShouldBeTrue();
m.Writable = false;
var saved = m.ToJson();
// The production export → import path (review r3 m2): the import base is the export RESIDUAL, not the row.
var csv = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Export(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus,
[new ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawCsvExportTag(null, "t", "Int16",
ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, false, null, PreLaneRow)]);
csv.ShouldContain(",InputRegisters,5,");
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Parse(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus, csv)
.Rows.ShouldHaveSingleItem();
row.Ok.ShouldBeTrue(row.Error);
var reimported = row.ImportRow!.Tag.TagConfig;
// The legacy string contradicts the keys under every family, so the import drops it — reported, not silent.
reimported.ShouldNotContain("addressString");
row.TypedOverrodeFallback.ShouldNotBeEmpty();
foreach (var json in new[] { saved, reimported })
foreach (var family in new[] { ModbusFamily.Generic, ModbusFamily.DL205, ModbusFamily.MELSEC })
{
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Region.ShouldBe(ModbusRegion.InputRegisters, family.ToString());
def.Address.ShouldBe((ushort)5, family.ToString());
}
}
/// <summary>Review r2 n5: a typed CSV address cell on a keyless row carrying a (stale) marker is an authored
/// address — it is applied and drops the string it contradicts. The unmarked string-only case is
/// <c>ModbusAddressAuthorityAgreementTests.A_typed_csv_address_cell_on_an_unmarked_string_only_row_…</c> (r3 m3).</summary>
[Fact]
public void A_typed_csv_address_cell_on_a_marker_bearing_keyless_row_is_applied_and_drops_the_string()
{
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F","addressResolved":true}""",
new Dictionary<string, string>(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
merged.ShouldNotContain("addressString");
merged.ShouldNotContain("addressResolved");
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", out var def).ShouldBeTrue();
def.Region.ShouldBe(ModbusRegion.InputRegisters);
def.Address.ShouldBe((ushort)7);
}
/// <summary>Review r2 n6: authoring is never stricter than the driver — a string-only tag's ignored dataType /
/// byteOrder keys are not judged (positive control: the same junk on a classic tag is refused).</summary>
[Fact]
public void Validate_does_not_judge_keys_the_driver_ignores_on_a_string_only_tag()
{
ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","dataType":"Intt16","byteOrder":"Nope"}""").Validate().ShouldBeNull();
ModbusTagConfigModel.FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}""").Validate().ShouldNotBeNull();
}
[Fact]
public void A_hand_edit_after_resolving_drops_the_address_string()
{
var m = ModbusTagConfigModel.FromJson("{}");
m.ResolveAddress("40011:F", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldBeNull();
m.ClearAddressString();
m.Address = 50;
var json = m.ToJson();
json.ShouldNotContain("addressString");
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)50);
}
}
@@ -0,0 +1,176 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
/// <summary>
/// Review R05:4 — the scripted-alarm comment trail moved off the condition row into its own
/// append-only <c>alarm_condition_comment</c> table. This proves the move kept the property that
/// mattered: the trail still reaches the redundant pair peer, over a real loopback h2c transport
/// through the real fail-closed interceptor, with both nodes initialised by the production
/// <c>LocalDbSetup.OnReady</c>.
/// </summary>
/// <remarks>
/// State is written and read through the production <see cref="LocalDbAlarmConditionStateStore"/>
/// on each node, so the ordinal keying, the incremental append and the load-side merge under
/// test are the ones production runs.
/// </remarks>
[Collection("LocalDbPairConvergence")]
public sealed class AlarmConditionCommentConvergenceTests
{
private static readonly DateTime T0 = new(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
private static LocalDbAlarmConditionStateStore Store(ILocalDb db) =>
new(db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task<int> CommentCountAsync(ILocalDb db, string alarmId) =>
(await Store(db).LoadAsync(alarmId, Ct))?.Comments.Count ?? -1;
[Fact]
public async Task CommentsWrittenOnA_ArriveOnB_AndBsAppendArrivesBackOnA()
{
await using var pair = new LocalDbPairHarness();
await pair.StartAsync();
var storeA = Store(pair.A);
var state = AlarmConditionState.Fresh("eq/alarm-1", T0) with
{
Active = AlarmActiveState.Active,
Acked = AlarmAckedState.Unacknowledged,
};
state = Part9StateMachine.ApplyAddComment(state, "jane", "first look", T0.AddMinutes(1)).State;
state = Part9StateMachine.ApplyAcknowledge(state, "bob", "on it", T0.AddMinutes(2)).State;
await storeA.SaveAsync(state, Ct);
await LocalDbPairHarness.WaitUntilAsync(
async () => await CommentCountAsync(pair.B, "eq/alarm-1") == 2,
"node B to hold node A's two comment-trail entries");
var onB = await Store(pair.B).LoadAsync("eq/alarm-1", Ct);
onB.ShouldNotBeNull();
onB.Comments.ShouldBe(state.Comments);
onB.Acked.ShouldBe(AlarmAckedState.Acknowledged);
// The reverse direction, as an incremental append: B adds one entry to the trail it loaded,
// and only that entry has to travel for A to see the full trail.
var appended = Part9StateMachine.ApplyAddComment(onB, "amy", "from B", T0.AddMinutes(3)).State;
await Store(pair.B).SaveAsync(appended, Ct);
await LocalDbPairHarness.WaitUntilAsync(
async () => await CommentCountAsync(pair.A, "eq/alarm-1") == 3,
"node A to receive the entry node B appended");
(await storeA.LoadAsync("eq/alarm-1", Ct))!.Comments.ShouldBe(appended.Comments);
}
[Fact]
public async Task Failover_AfterAMissedCommand_BothTheMissedEntryAndTheSurvivorsNextEntry_ReachBothPeers()
{
// Review I-1, end to end over the real pair:
// 1. a shared trail on both nodes;
// 2. an AddComment reaches only node A (B's command delivery failed) — A's entry replicates
// into B's table, but B's engine never applied it, so B's IN-MEMORY trail is one short;
// 3. A goes away (link down) and the operator acks on the survivor B — B's in-memory Ack
// collides with A's entry at the same ordinal;
// 4. the pair rejoins.
// Both entries must survive on both peers. Before the fix B's Ack was silently dropped.
await using var pair = new LocalDbPairHarness();
await pair.StartAsync();
var shared = AlarmConditionState.Fresh("eq/alarm-fo", T0) with
{
Active = AlarmActiveState.Active,
Acked = AlarmAckedState.Unacknowledged,
};
for (var i = 0; i < 3; i++)
shared = Part9StateMachine.ApplyAddComment(shared, "jane", $"note {i}", T0.AddMinutes(i)).State;
await Store(pair.A).SaveAsync(shared, Ct);
await LocalDbPairHarness.WaitUntilAsync(
async () => await CommentCountAsync(pair.B, "eq/alarm-fo") == 3,
"node B to hold the shared trail");
var onlyOnA = Part9StateMachine.ApplyAddComment(shared, "jane", "seen on A only", T0.AddHours(1)).State;
await Store(pair.A).SaveAsync(onlyOnA, Ct);
await LocalDbPairHarness.WaitUntilAsync(
async () => await CommentCountAsync(pair.B, "eq/alarm-fo") == 4,
"node A's entry to replicate into node B's table");
await pair.StopPassiveAsync();
// B's engine still holds the 3-entry trail it applied; the operator acks there.
var ackOnB = Part9StateMachine.ApplyAcknowledge(shared, "bob", "acked on B", T0.AddHours(2)).State;
await Store(pair.B).SaveAsync(ackOnB, Ct);
await pair.RestartPairAsync();
await LocalDbPairHarness.WaitUntilAsync(
async () => await CommentCountAsync(pair.A, "eq/alarm-fo") == 5
&& await CommentCountAsync(pair.B, "eq/alarm-fo") == 5,
"both nodes to hold the shared trail + A's missed-by-B entry + B's ack");
var onA = (await Store(pair.A).LoadAsync("eq/alarm-fo", Ct))!;
var onB = (await Store(pair.B).LoadAsync("eq/alarm-fo", Ct))!;
onA.Comments.ShouldBe(onB.Comments);
onA.Comments[3].ShouldBe(onlyOnA.Comments[3]);
onA.Comments[4].ShouldBe(ackOnB.Comments[3]);
// Held, not merely reached: nothing late duplicates either entry.
(await LocalDbPairHarness.HeldWithinAsync(
async () => await CommentCountAsync(pair.A, "eq/alarm-fo") != 5
|| await CommentCountAsync(pair.B, "eq/alarm-fo") != 5,
TimeSpan.FromSeconds(2)))
.ShouldBeFalse();
}
[Fact]
public async Task BothNodesRecordingTheSameEntry_ConvergeToOneEntry_NotTwo()
{
// Both pair nodes run the engine and both apply every operator command, each stamping the
// entry with its own clock. Ordinal keying is what collapses the two copies to one entry;
// a content-derived key would leave the trail doubled on both nodes.
await using var pair = new LocalDbPairHarness();
await pair.StartAsync();
var fresh = AlarmConditionState.Fresh("eq/alarm-2", T0);
var onA = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1)).State;
var onB = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1).AddMilliseconds(7)).State;
await Store(pair.A).SaveAsync(onA, Ct);
await Store(pair.B).SaveAsync(onB, Ct);
// Positive control: a DISTINCT second alarm written only on B. Without it "one entry on
// both" would also hold for two databases that never replicated at all.
var onlyOnB = Part9StateMachine.ApplyAddComment(
AlarmConditionState.Fresh("eq/only-on-b", T0), "amy", "b only", T0.AddSeconds(2)).State;
await Store(pair.B).SaveAsync(onlyOnB, Ct);
await LocalDbPairHarness.WaitUntilAsync(
async () => await CommentCountAsync(pair.A, "eq/only-on-b") == 1,
"node A to receive node B's distinct alarm");
// LWW picks ONE node's copy and both nodes end up holding that same copy.
await LocalDbPairHarness.WaitUntilAsync(
async () =>
{
var a = await Store(pair.A).LoadAsync("eq/alarm-2", Ct);
var b = await Store(pair.B).LoadAsync("eq/alarm-2", Ct);
return a is not null && b is not null
&& a.Comments.Count == 1 && b.Comments.Count == 1
&& a.Comments[0] == b.Comments[0];
},
"both nodes to agree on a single copy of the shared entry");
// Held, not merely reached: nothing late may double the trail on either node.
(await LocalDbPairHarness.HeldWithinAsync(
async () => await CommentCountAsync(pair.A, "eq/alarm-2") != 1
|| await CommentCountAsync(pair.B, "eq/alarm-2") != 1,
TimeSpan.FromSeconds(2)))
.ShouldBeFalse("the shared entry must stay one entry on both nodes, never two");
}
}
@@ -134,7 +134,7 @@ public sealed class LocalDbSetupTests : IDisposable
TestContext.Current.CancellationToken);
[Fact]
public void OnReady_WhenReplicationIsConfigured_RegistersExactlyTheFourReplicatedTables()
public void OnReady_WhenReplicationIsConfigured_RegistersExactlyTheFiveReplicatedTables()
{
// Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
// (that table then never replicates). "No more" catches an accidental registration —
@@ -142,11 +142,11 @@ public sealed class LocalDbSetupTests : IDisposable
var db = Boot(InitiatorReplication);
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
}
[Fact]
public void OnReady_WhenOnlyTheSyncApiKeyIsSet_StillRegistersTheFourTables()
public void OnReady_WhenOnlyTheSyncApiKeyIsSet_StillRegistersTheFiveTables()
{
// The passive half of a pair carries the key and nothing else. If the predicate were
// "PeerAddress is set", this node would install no capture triggers and its OWN writes
@@ -155,7 +155,7 @@ public sealed class LocalDbSetupTests : IDisposable
var db = Boot(PassiveReplication);
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
}
[Fact]
@@ -216,6 +216,56 @@ public sealed class LocalDbSetupTests : IDisposable
db.ReplicatedTables["alarm_condition_state"].PkColumns.ShouldBe(["scripted_alarm_id"]);
}
[Fact]
public void AlarmConditionComment_PkIsTheAlarmIdPlusTheTrailOrdinal()
{
// One row per trail entry. Keyed by ordinal (not content) so the pair's two copies of the
// same logical entry — each stamped by its own node's clock — collapse to one row under LWW.
var db = Boot(InitiatorReplication);
db.ReplicatedTables["alarm_condition_comment"].PkColumns.ShouldBe(["scripted_alarm_id", "seq"]);
}
[Fact]
public async Task OnReady_MovesAnOldShapeInlineCommentTrail_AfterRegistration_SoTheMoveReplicates()
{
// A file written by the pre-split store: the trail is inline on the condition row.
var first = Boot(InitiatorReplication);
await first.ExecuteAsync(
"""
INSERT INTO alarm_condition_state
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
comments_json, updated_at_utc)
VALUES ('eq/alarm-legacy', 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved',
'[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"Acknowledge","text":"a"},{"timestampUtc":"2026-06-10T10:05:00Z","user":"bob","kind":"Confirm","text":"b"}]',
'2026-06-10T10:05:00.0000000Z')
""",
parameters: null,
TestContext.Current.CancellationToken);
// Positive control: before the restart the trail is inline and the comment table is empty.
(await ScalarAsync(first, "SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(0);
// The upgrade restart.
var db = Boot(InitiatorReplication);
(await ScalarAsync(db, "SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'eq/alarm-legacy'"))
.ShouldBe(2);
// The condition row itself is NOT rewritten (review m1): a migration UPDATE would re-stamp it
// with a fresh HLC and could beat a newer peer state under LWW. Only the seeding INSERT is in
// the oplog for it; the alarm's next save clears the inline column.
(await ScalarAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_state'"))
.ShouldBe(1);
// Captured: the migration ran after RegisterReplicated, so the moved entries reach the peer.
(await ScalarAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'"))
.ShouldBe(2);
// Idempotent: a further boot writes nothing more.
var third = Boot(InitiatorReplication);
(await ScalarAsync(third, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'"))
.ShouldBe(2);
(await ScalarAsync(third, "SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(2);
}
[Fact]
public async Task AlarmSfEventRows_EnterTheOplog()
{
@@ -296,7 +346,7 @@ public sealed class LocalDbSetupTests : IDisposable
var replicated = Boot(InitiatorReplication);
await InsertPointerAsync(replicated, "SITE-A");
(await ScalarAsync(replicated, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger'"))
.ShouldBe(12);
.ShouldBe(15);
var unreplicated = Boot();
@@ -350,6 +400,45 @@ public sealed class LocalDbSetupTests : IDisposable
sink.Events.Count(e => e.Level == LogEventLevel.Information).ShouldBe(1);
}
[Fact]
public async Task OnReady_WarnsOncePerAlarmWhoseInlineCommentTrailIsUnreadable()
{
// Review m2: an unmigratable history must be visible, not skipped silently on every boot.
var first = Boot();
await first.ExecuteAsync(
"""
INSERT INTO alarm_condition_state
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
comments_json, updated_at_utc)
VALUES (@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved', @Json, '2026-06-10T10:05:00.0000000Z')
""",
new { Id = "eq/alarm-bad", Json = "{not json" },
TestContext.Current.CancellationToken);
await first.ExecuteAsync(
"""
INSERT INTO alarm_condition_state
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
comments_json, updated_at_utc)
VALUES (@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved', @Json, '2026-06-10T10:05:00.0000000Z')
""",
new
{
Id = "eq/alarm-good",
Json = """[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"AddComment","text":"a"}]""",
},
TestContext.Current.CancellationToken);
var sink = new CollectingSink();
Boot(replication: null, logger: BuildLogger(sink));
var warnings = sink.Events.Where(e => e.Level == LogEventLevel.Warning).ToList();
warnings.Count.ShouldBe(1);
warnings[0].RenderMessage().ShouldContain("eq/alarm-bad");
// Positive control: the readable row DID move, and said so.
sink.Events.Count(e => e.Level == LogEventLevel.Information && e.RenderMessage().Contains("copied"))
.ShouldBe(1);
}
[Fact]
public void DisablingReplication_SaysNothingWhenThereWasNothingToClean()
{
@@ -365,13 +454,13 @@ public sealed class LocalDbSetupTests : IDisposable
[Fact]
public void AFailureMidwayThroughRegistration_FailsBootLoudly_AndTheRetriedBootConverges()
{
// OnReady flips four tables in a loop with no try/catch, so a failure on the third leaves
// two registered and two not. That is the RIGHT behaviour and this pins both halves of why:
// OnReady flips five tables in a loop with no try/catch, so a failure on the third leaves
// two registered and three not. That is the RIGHT behaviour and this pins both halves of why:
// the throw propagates out of the AddZbLocalDb singleton factory and fails host start
// (a node half-capturing is a node whose replication is silently partial), and because both
// library APIs are idempotent the retried boot completes the flip rather than compounding it.
//
// Swallowing the throw would leave the node up, serving, and replicating two of four tables
// Swallowing the throw would leave the node up, serving, and replicating two of five tables
// with nothing in the log to say so.
var real = Boot(InitiatorReplication);
@@ -387,11 +476,11 @@ public sealed class LocalDbSetupTests : IDisposable
// pinning some unrelated throw, and the "partial flip" it claims to model never happened.
faulty.Calls.ShouldBe(3);
// The retry: same file, no wrapper. Idempotent re-registration converges on all four.
// The retry: same file, no wrapper. Idempotent re-registration converges on all five.
LocalDbSetup.OnReady(real, configuration);
real.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
}
/// <summary>
@@ -108,7 +108,7 @@ public sealed class LocalDbWiringTests : IDisposable
// Exact set, both directions — a dropped registration replicates nothing; an extra one costs
// three triggers and oplog volume on every write.
first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
}
[Fact]
@@ -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" };
@@ -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();
}
}
@@ -0,0 +1,523 @@
using System.Collections.Immutable;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
/// <summary>
/// The alarm comment audit trail lives in its own append-only table
/// (<see cref="AlarmConditionStateSchema.CommentsTable"/>) keyed by alarm id, so the condition
/// row stays a fixed size and a transition's write — and therefore its replication payload —
/// does not grow with the alarm's history.
/// </summary>
/// <remarks>
/// The payload is measured where it costs: the bytes the LocalDb capture triggers write into
/// <c>__localdb_oplog</c> for one save, which is exactly what ships to the pair peer. Both
/// tables are registered for capture here, as they are on a replicating node.
/// </remarks>
public sealed class LocalDbAlarmCommentAuditTests : IDisposable
{
private static readonly DateTime T0 = new(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"otopcua-alarm-comments-{Guid.NewGuid():N}.db");
private readonly ServiceProvider _provider;
private readonly ILocalDb _db;
public LocalDbAlarmCommentAuditTests()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
.Build();
_provider = new ServiceCollection()
.AddZbLocalDb(configuration, db =>
{
using (var connection = db.CreateConnection())
{
AlarmConditionStateSchema.Apply(connection);
}
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
db.RegisterReplicated(AlarmConditionStateSchema.CommentsTable);
})
.BuildServiceProvider();
_db = _provider.GetRequiredService<ILocalDb>();
}
public void Dispose()
{
_provider.Dispose();
SqliteConnection.ClearAllPools();
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
}
}
private LocalDbAlarmConditionStateStore NewStore()
=> new(_db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
private static CancellationToken Ct => CancellationToken.None;
private static ImmutableList<AlarmComment> Comments(int count, string prefix = "c")
{
var builder = ImmutableList.CreateBuilder<AlarmComment>();
for (var i = 0; i < count; i++)
builder.Add(new AlarmComment(T0.AddSeconds(i), "jane", "AddComment", $"{prefix}-{i:D6}"));
return builder.ToImmutable();
}
/// <summary>An unacknowledged state carrying <paramref name="comments"/>, ready to be acked.</summary>
private static AlarmConditionState Unacked(string alarmId, ImmutableList<AlarmComment> comments) =>
AlarmConditionState.Fresh(alarmId, T0) with
{
Active = AlarmActiveState.Active,
Acked = AlarmAckedState.Unacknowledged,
Comments = comments,
};
private async Task<long> OplogHighWaterAsync() =>
(await _db.QueryAsync("SELECT COALESCE(MAX(seq), 0) FROM __localdb_oplog", r => r.GetInt64(0), null, Ct))[0];
/// <summary>Bytes captured into the oplog above <paramref name="afterSeq"/> — the replication payload.</summary>
private async Task<long> OplogBytesAboveAsync(long afterSeq) =>
(await _db.QueryAsync(
"SELECT COALESCE(SUM(LENGTH(pk_json) + COALESCE(LENGTH(row_json), 0)), 0) FROM __localdb_oplog WHERE seq > @S",
r => r.GetInt64(0),
new { S = afterSeq },
Ct))[0];
private async Task<long> OplogEntriesAboveAsync(long afterSeq, string table) =>
(await _db.QueryAsync(
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > @S AND table_name = @T",
r => r.GetInt64(0),
new { S = afterSeq, T = table },
Ct))[0];
private async Task<long> ScalarAsync(string sql) =>
(await _db.QueryAsync(sql, r => r.GetInt64(0), null, Ct))[0];
/// <summary>
/// Seeds <paramref name="alarmId"/> with <paramref name="historyLength"/> comments, then
/// returns the oplog bytes one acknowledge transition (one new comment) costs.
/// </summary>
private async Task<long> AckTransitionBytesAsync(string alarmId, int historyLength, string ackComment)
{
var store = NewStore();
var seeded = Unacked(alarmId, Comments(historyLength));
await store.SaveAsync(seeded, Ct);
var before = await OplogHighWaterAsync();
var acked = Part9StateMachine.ApplyAcknowledge(seeded, "bob", ackComment, T0.AddDays(1)).State;
await store.SaveAsync(acked, Ct);
return await OplogBytesAboveAsync(before);
}
[Fact]
public async Task TransitionPayload_IsConstant_WhetherTheAlarmCarries10OrTenThousandComments()
{
var small = await AckTransitionBytesAsync("alarm-small", 10, "ack");
var large = await AckTransitionBytesAsync("alarm-large", 10_000, "ack");
// Constant: the only difference allowed is the width of the new comment's ordinal ("10" vs
// "10000", in pk_json and row_json) — a handful of bytes, not O(n). The alarm ids are the
// same length on purpose.
small.ShouldBeGreaterThan(0);
Math.Abs(large - small).ShouldBeLessThanOrEqualTo(16,
$"one ack transition cost {small} bytes at 10 comments but {large} at 10,000");
large.ShouldBeLessThan(2_048);
// Positive control on the instrument: the seeded history really is large and really was
// captured — otherwise a "constant" result could just mean capture is off.
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-large'"))
.ShouldBe(10_001);
var historyBytes = await ScalarAsync(
"SELECT SUM(LENGTH(row_json)) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'");
historyBytes.ShouldBeGreaterThan(1_000_000);
}
[Fact]
public async Task TransitionPayload_PositiveControl_TheMeasurementSeesTheNewCommentsBytes()
{
// Proves the measurement above is not vacuous: the oplog delta of a transition does carry
// the appended comment, so a 4 KB comment costs at least 4 KB.
var plain = await AckTransitionBytesAsync("alarm-plain", 10, "ack");
var heavy = await AckTransitionBytesAsync("alarm-heavy", 10, new string('x', 4_096));
(heavy - plain).ShouldBeGreaterThanOrEqualTo(4_096 * 2 - 16,
"the comment text rides both the comment row and last_ack_comment on the condition row");
}
[Fact]
public async Task Transition_WritesOneConditionRowAndOnlyTheAppendedCommentRow()
{
var store = NewStore();
var seeded = Unacked("alarm-append", Comments(50));
await store.SaveAsync(seeded, Ct);
var before = await OplogHighWaterAsync();
var acked = Part9StateMachine.ApplyAcknowledge(seeded, "bob", "ack", T0.AddDays(1)).State;
await store.SaveAsync(acked, Ct);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(1);
var loaded = await store.LoadAsync("alarm-append", Ct);
loaded.ShouldNotBeNull();
loaded.Comments.ShouldBe(acked.Comments);
}
[Fact]
public async Task ConditionRow_StaysFixedSize_InlineColumnIsAlwaysEmpty()
{
var store = NewStore();
await store.SaveAsync(Unacked("alarm-fixed", Comments(500)), Ct);
(await _db.QueryAsync(
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-fixed'",
r => r.GetString(0), null, Ct))[0]
.ShouldBe("[]");
(await store.LoadAsync("alarm-fixed", Ct))!.Comments.Count.ShouldBe(500);
}
[Fact]
public async Task UnchangedSave_WritesNoCommentRows()
{
var store = NewStore();
var state = Unacked("alarm-idle", Comments(20));
await store.SaveAsync(state, Ct);
var before = await OplogHighWaterAsync();
await store.SaveAsync(state with { LastTransitionUtc = T0.AddMinutes(1) }, Ct);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(0);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1,
"positive control: the save itself was captured");
}
[Fact]
public async Task LoadAll_ReturnsEachAlarmsOwnTrailInOrder()
{
var store = NewStore();
var a = Unacked("alarm-a", Comments(3, "a"));
var b = Unacked("alarm-b", Comments(5, "b"));
await store.SaveAsync(a, Ct);
await store.SaveAsync(b, Ct);
var all = (await store.LoadAllAsync(Ct)).ToDictionary(s => s.AlarmId);
all["alarm-a"].Comments.ShouldBe(a.Comments);
all["alarm-b"].Comments.ShouldBe(b.Comments);
}
[Fact]
public async Task Remove_DeletesTheAlarmsTrail_AndAReCreatedAlarmStartsEmpty()
{
var store = NewStore();
await store.SaveAsync(Unacked("alarm-gone", Comments(4)), Ct);
await store.SaveAsync(Unacked("alarm-kept", Comments(2)), Ct);
await store.RemoveAsync("alarm-gone", Ct);
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-gone'"))
.ShouldBe(0);
(await store.LoadAsync("alarm-kept", Ct))!.Comments.Count.ShouldBe(2, "positive control");
var recreated = AlarmConditionState.Fresh("alarm-gone", T0)
with { Comments = ImmutableList.Create(new AlarmComment(T0, "amy", "AddComment", "new life")) };
await store.SaveAsync(recreated, Ct);
(await store.LoadAsync("alarm-gone", Ct))!.Comments.ShouldBe(recreated.Comments);
}
[Fact]
public async Task CommentsAlreadyHeldAtAnOrdinal_AreNeverRewritten()
{
// Append-only: a save whose list disagrees with what is stored at an existing ordinal (the
// peer's copy of the same logical entry already arrived) leaves the stored entry alone.
var store = NewStore();
var original = Unacked("alarm-ao", Comments(3));
await store.SaveAsync(original, Ct);
var rewritten = original with { Comments = original.Comments.SetItem(1, new AlarmComment(T0, "eve", "AddComment", "forged")) };
await store.SaveAsync(rewritten, Ct);
// The stored entry at ordinal 1 is untouched; the unrecognised local entry is kept by
// appending it (never dropped), not by overwriting history.
var loaded = (await store.LoadAsync("alarm-ao", Ct))!.Comments;
loaded.Take(3).ShouldBe(original.Comments);
loaded.Count.ShouldBe(4);
loaded[3].Text.ShouldBe("forged");
}
// ---- back-compat + migration -----------------------------------------------------------
/// <summary>
/// The exact shape the pre-split store wrote into <c>comments_json</c>
/// (<c>JsonSerializerDefaults.Web</c>: camelCase, Utc timestamps with a trailing <c>Z</c>).
/// </summary>
private const string OldShapeCommentsJson =
"""[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"Acknowledge","text":"checking pump"},{"timestampUtc":"2026-06-10T11:30:00.1234567Z","user":"bob","kind":"Confirm","text":"all clear"},{"timestampUtc":"2026-06-10T11:30:00.1234567Z","user":"system","kind":"AutoUnshelve","text":""}]""";
private static readonly ImmutableList<AlarmComment> OldShapeComments = ImmutableList.Create(
new AlarmComment(new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump"),
new AlarmComment(new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc).AddTicks(1_234_567), "bob", "Confirm", "all clear"),
new AlarmComment(new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc).AddTicks(1_234_567), "system", "AutoUnshelve", ""));
private async Task InsertOldShapeRowAsync(string alarmId, string commentsJson = OldShapeCommentsJson) =>
await _db.ExecuteAsync(
"""
INSERT INTO alarm_condition_state
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc,
last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc)
VALUES
(@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved',
NULL, 'jane', 'checking pump', '2026-06-10T10:00:00.0000000Z',
'bob', 'all clear', '2026-06-10T11:30:00.1234567Z', @Json, '2026-06-10T11:30:00.1234567Z')
""",
new { Id = alarmId, Json = commentsJson },
Ct);
private static void ShouldBeTheOldShapeState(AlarmConditionState? loaded, string alarmId)
{
loaded.ShouldNotBeNull();
loaded.AlarmId.ShouldBe(alarmId);
loaded.Acked.ShouldBe(AlarmAckedState.Acknowledged);
loaded.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
loaded.LastAckUser.ShouldBe("jane");
loaded.LastConfirmComment.ShouldBe("all clear");
loaded.Comments.ShouldBe(OldShapeComments);
loaded.Comments.ShouldAllBe(c => c.TimestampUtc.Kind == DateTimeKind.Utc);
}
[Fact]
public async Task OldShapeRow_WithInlineComments_LoadsExactlyAsBefore()
{
await InsertOldShapeRowAsync("alarm-legacy");
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-legacy", Ct), "alarm-legacy");
ShouldBeTheOldShapeState((await NewStore().LoadAllAsync(Ct)).Single(), "alarm-legacy");
}
[Fact]
public async Task Migration_CopiesInlineCommentsToTheAuditTable_AndNeverRewritesTheConditionRow()
{
await InsertOldShapeRowAsync("alarm-m1");
await InsertOldShapeRowAsync("alarm-m2");
await InsertOldShapeRowAsync("alarm-empty", "[]");
var before = await OplogHighWaterAsync();
AlarmConditionStateSchema.InlineCommentMigrationResult result;
using (var connection = _db.CreateConnection())
result = AlarmConditionStateSchema.MigrateInlineComments(connection);
result.RowsMoved.ShouldBe(2);
result.UnparseableAlarmIds.ShouldBeEmpty();
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(6);
// Review m1: the migration must not touch the condition row. A full-row UPDATE re-stamps it
// with a fresh HLC and can revert a newer peer state (e.g. an ack) under LWW. The next save
// (the engine's boot save) resets the column instead.
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(0);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(6,
"positive control: the copied entries were captured");
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-m1", Ct), "alarm-m1");
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-m2", Ct), "alarm-m2");
(await NewStore().LoadAsync("alarm-empty", Ct))!.Comments.ShouldBeEmpty();
}
[Fact]
public async Task Migration_IsIdempotent_ASecondRunChangesNothing()
{
await InsertOldShapeRowAsync("alarm-idem");
using (var connection = _db.CreateConnection())
AlarmConditionStateSchema.MigrateInlineComments(connection).RowsMoved.ShouldBe(1, "positive control");
var before = await OplogHighWaterAsync();
using (var connection = _db.CreateConnection())
AlarmConditionStateSchema.MigrateInlineComments(connection).RowsMoved.ShouldBe(0);
(await OplogHighWaterAsync()).ShouldBe(before, "a re-run must write nothing");
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(3);
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-idem", Ct), "alarm-idem");
}
[Fact]
public async Task Migration_OverAPartiallyMigratedRow_FillsOnlyTheMissingOrdinals()
{
// Only ordinal 0 made it across (e.g. a restored backup): the run fills 1 and 2, keeps 0,
// duplicates nothing.
await InsertOldShapeRowAsync("alarm-partial");
await _db.ExecuteAsync(
AlarmConditionStateSchema.InsertCommentSql,
new
{
Id = "alarm-partial", Seq = 0,
Ts = AlarmConditionStateSchema.FormatTimestamp(OldShapeComments[0].TimestampUtc),
User = OldShapeComments[0].User, Kind = OldShapeComments[0].Kind, Text = OldShapeComments[0].Text,
},
Ct);
using (var connection = _db.CreateConnection())
AlarmConditionStateSchema.MigrateInlineComments(connection).RowsMoved.ShouldBe(1);
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(3);
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-partial", Ct), "alarm-partial");
}
[Fact]
public async Task Migration_ReportsAnUnparseableInlineTrail_AndLeavesItAlone()
{
// Review m2: an unreadable trail must surface (OnReady logs a Warning per id), not be
// skipped silently on every boot.
await InsertOldShapeRowAsync("alarm-bad", "{not json");
await InsertOldShapeRowAsync("alarm-good");
AlarmConditionStateSchema.InlineCommentMigrationResult result;
using (var connection = _db.CreateConnection())
result = AlarmConditionStateSchema.MigrateInlineComments(connection);
result.UnparseableAlarmIds.ShouldBe(["alarm-bad"]);
result.RowsMoved.ShouldBe(1, "positive control: the good row still moved");
(await _db.QueryAsync(
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-bad'",
r => r.GetString(0), null, Ct))[0]
.ShouldBe("{not json");
}
// ---- divergence / failover (review I-1) -------------------------------------------------
/// <summary>Captures log entries so a divergence Warning can be asserted, not inferred.</summary>
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<LocalDbAlarmConditionStateStore>
{
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(
Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
public int Warnings => Entries.Count(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning);
}
[Fact]
public async Task Failover_TheSurvivorsNextEntry_IsPersisted_AfterItMissedACommandThePeerApplied()
{
// The I-1 sequence on node B's database:
// 1. a shared trail of 5 entries;
// 2. an AddComment reaches only node A (B's command delivery failed once) and A's row at
// ordinal 5 replicates into B's table — B's IN-MEMORY trail still has 5 entries;
// 3. A dies and the operator acks on B: B's in-memory Ack sits at index 5, colliding with
// A's entry at ordinal 5.
// The Ack must be persisted (and say so), not silently treated as "already stored".
var shared = Unacked("alarm-fo", Comments(5));
var a = NewStore();
await a.SaveAsync(shared, Ct);
var onlyOnA = Part9StateMachine.ApplyAddComment(shared, "jane", "seen on A only", T0.AddHours(1)).State;
await a.SaveAsync(onlyOnA, Ct);
var log = new CapturingLogger();
var b = new LocalDbAlarmConditionStateStore(_db, log);
var ackOnB = Part9StateMachine.ApplyAcknowledge(shared, "bob", "acked on B", T0.AddHours(2)).State;
await b.SaveAsync(ackOnB, Ct);
var loaded = (await NewStore().LoadAsync("alarm-fo", Ct))!;
loaded.Comments.Count.ShouldBe(7);
loaded.Comments[5].ShouldBe(onlyOnA.Comments[5]);
loaded.Comments[6].ShouldBe(ackOnB.Comments[5]);
log.Warnings.ShouldBe(1, "a divergence is never silent");
// Re-saving with no new entry must not append the Ack a second time (its own earlier
// divergence-append shifted it off its in-memory index).
var before = await OplogHighWaterAsync();
await b.SaveAsync(ackOnB with { LastTransitionUtc = T0.AddHours(3) }, Ct);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(0);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1,
"positive control: the re-save itself was captured");
// And B's following entry (still one behind in memory) is persisted too, exactly once.
var confirmOnB = Part9StateMachine.ApplyAddComment(ackOnB, "bob", "follow-up on B", T0.AddHours(4)).State;
await b.SaveAsync(confirmOnB, Ct);
await b.SaveAsync(confirmOnB, Ct);
var after = (await NewStore().LoadAsync("alarm-fo", Ct))!;
after.Comments.Count.ShouldBe(8);
after.Comments[7].ShouldBe(confirmOnB.Comments[6]);
}
[Fact]
public async Task PeersCopyOfTheSameCommand_IsNotAppendedAgain_AndLogsNothing()
{
// The positive control for the divergence path: in normal operation both nodes apply every
// command, each with its own clock. When the peer's copy is already at the ordinal, the
// local copy is the same entry and must NOT be appended — otherwise the fix would double
// every entry on a healthy pair.
var shared = Unacked("alarm-dual", Comments(5));
await NewStore().SaveAsync(shared, Ct);
var ackOnA = Part9StateMachine.ApplyAcknowledge(shared, "bob", "on it", T0.AddHours(1)).State;
await NewStore().SaveAsync(ackOnA, Ct);
var log = new CapturingLogger();
var b = new LocalDbAlarmConditionStateStore(_db, log);
var ackOnB = Part9StateMachine.ApplyAcknowledge(shared, "bob", "on it", T0.AddHours(1).AddMilliseconds(40)).State;
var before = await OplogHighWaterAsync();
await b.SaveAsync(ackOnB, Ct);
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(0);
(await NewStore().LoadAsync("alarm-dual", Ct))!.Comments.Count.ShouldBe(6);
log.Warnings.ShouldBe(0);
}
[Fact]
public async Task ARepeatedIdenticalComment_IsKept_AsItsOwnEntry()
{
// Matching ignores the timestamp only within the clock-skew window; an operator repeating the
// same text later is a new entry, not a duplicate of the earlier one.
var store = NewStore();
var s = Unacked("alarm-repeat", Comments(2));
s = Part9StateMachine.ApplyAddComment(s, "amy", "still high", T0.AddHours(1)).State;
await store.SaveAsync(s, Ct);
s = Part9StateMachine.ApplyAddComment(s, "amy", "still high", T0.AddHours(2)).State;
await store.SaveAsync(s, Ct);
(await NewStore().LoadAsync("alarm-repeat", Ct))!.Comments.ShouldBe(s.Comments);
}
[Fact]
public async Task SavingALoadedOldShapeState_MovesItsCommentsAndAppendsTheNewOne()
{
// The lazy path: an old-shape row the boot migration never saw (it was written before the
// first save on this build) still converges on the first save.
await InsertOldShapeRowAsync("alarm-lazy");
var store = NewStore();
var loaded = await store.LoadAsync("alarm-lazy", Ct);
var next = Part9StateMachine.ApplyAddComment(loaded!, "amy", "follow-up", T0.AddDays(2)).State;
await store.SaveAsync(next, Ct);
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-lazy'"))
.ShouldBe(4);
(await _db.QueryAsync(
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-lazy'",
r => r.GetString(0), null, Ct))[0]
.ShouldBe("[]");
(await NewStore().LoadAsync("alarm-lazy", Ct))!.Comments.ShouldBe(next.Comments);
}
}
@@ -48,6 +48,7 @@ public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
}
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
db.RegisterReplicated(AlarmConditionStateSchema.CommentsTable);
})
.BuildServiceProvider();