Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusFamilyWiringTests.cs
T
Joseph Doherty 92749402b5 fix(modbus): review r3 - one address authority (ModbusTagAddressResolution)
N3: a keyless blob carrying the addressResolved marker was served from its
string by the driver but saved as HR[0] by the editor model / CSV path. The
precedence rule now lives in ONE pure resolver in Driver.Modbus.Contracts
(ModbusTagAddressResolution.Classify/Resolve): keys when region+address are
both present, else a string-only addressString resolved with the driver's
family (refused when it cannot parse), else refused. The marker never decides.

Consumers that now ask it instead of restating it: the driver's tag reader
(ModbusTagDefinitionFactory.FromTagConfig), the deploy gate (Inspect /
DescribeRejection), the editor model (AddressShape / IsStringOnly /
HasKeyAddress / ResolveWith, ToJson stamping, Validate), the CSV export cells
and the editor hint. The model writes keys only when it loaded them or a field
was authored - never defaults it did not resolve (also closes two divergences
the new matrix found: malformed-string+marker and non-string addressString were
saved as HR[0]).

One table-driven test (ModbusAddressAuthorityAgreementTests) runs 15 persisted
shapes through driver, deploy gate, model load/save/Validate and the real CSV
export->import under all four families; breaking the CSV export or reverting
the model to the marker rule fails it.

Minors: m1 CSV address cells keep the string+marker while it still describes
them (lossless unchanged round trip), drop it when contradicted, and the review
grid reports the drop; m2 the pre-lane test uses the real export residual; m3
unmarked string-only + typed-cell case added; m4 stale docs/summary and the
unused factory parameter removed.

Refs #741

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 16:46:21 -04:00

253 lines
13 KiB
C#

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));
}
}