From 0b7653d3b28a2c33c7f7dc13a776f837b4b523fe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 00:58:03 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#147=20=E2=80=94=20wire=20ModbusOptionsE?= =?UTF-8?q?ditor=20into=20DriversTab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branches the DriversTab driver-add form on driver type: - For DriverType=Modbus, render the typed component shipped in #145 instead of the generic JSON textarea. - For other driver types, the existing textarea stays (other drivers ship their own typed editors per decision #94). On Save, when type is Modbus, the form serialises ModbusOptionsViewModel into the JSON DTO shape ModbusDriverFactoryExtensions consumes (host / port / unitId / family / keepAlive / reconnect / max*** / writeOnChangeOnly / etc.). Other types still pass the textarea contents verbatim. Drive-by fix: the DriverType dropdown listed "ModbusTcp" but the actual factory-registered name is "Modbus" — DriverInstanceBootstrapper would silently skip a row created with the old label because the factory lookup would miss. Renamed to match. Tests (2 new in ModbusOptionsViewModelTests): - DriversTab_Serialized_Defaults_RoundTrip_Through_Factory — unedited view-model serializes to a JSON the factory accepts; resulting ModbusDriverOptions matches the form defaults bit-for-bit. - DriversTab_Serializes_Edited_Values_Correctly — flipping Host / Port / UnitId / Family / MaxReadGap / WriteOnChangeOnly in the view model surfaces in the constructed driver's options. The serializer in the test mirrors DriversTab.razor's SerializeModbusOptions helper. If the form's serialization shape drifts, both must be updated together; that's the cost of testing through the JSON DTO without bUnit. Follow-up still open: the per-tag editor (ModbusAddressEditor wiring into EquipmentTab.razor + the bulk-import help-text update) — that's a separate surface that touches the equipment-row CRUD flow; covered as a follow-up when the equipment tag editor surface is next touched. --- .../Pages/Clusters/DriversTab.razor | 77 ++++++++++++++-- .../ModbusOptionsViewModelTests.cs | 90 +++++++++++++++++++ 2 files changed, 161 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor index 3bb2631..ba30127 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor @@ -1,3 +1,5 @@ +@using System.Text.Json +@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus @using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject DriverInstanceService DriverSvc @@ -50,13 +52,14 @@ else +
Type string must match the driver's registered factory name; this dropdown wraps the canonical names.
@@ -65,9 +68,19 @@ else
- - -
Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).
+ @if (string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase)) + { + @* #147 — typed editor for Modbus drivers. The generic textarea is a fall-back + for driver types that haven't yet shipped a typed editor. *@ + + + } + else + { + + +
Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).
+ }
@if (_error is not null) {
@_error
} @@ -87,11 +100,17 @@ else private List? _namespaces; private bool _showForm; private string _name = string.Empty; - private string _type = "ModbusTcp"; + private string _type = "Modbus"; private string _nsId = string.Empty; private string _config = "{}"; private string? _error; + // #147 — typed editor model for Modbus drivers. Defaults match ModbusDriverOptions + // defaults so an unedited form produces config equivalent to the historical + // pre-typed-editor wire output. Serialised to _config on Save when type=Modbus. + private ModbusOptionsEditor.ModbusOptionsViewModel _modbusOptions = new(); + private static readonly JsonSerializerOptions ModbusJsonOptions = new() { WriteIndented = true }; + protected override async Task OnParametersSetAsync() => await ReloadAsync(); private async Task ReloadAsync() @@ -111,11 +130,57 @@ else } try { - await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, _config, CancellationToken.None); + // #147 — for Modbus drivers serialize the typed editor model into the DriverConfig + // JSON column. Other driver types still use the raw textarea contents until each + // ships its own typed editor (decision #94 — per-driver schema validation arrives + // per driver phase). + var configJson = string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase) + ? SerializeModbusOptions(_modbusOptions) + : _config; + + await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, configJson, CancellationToken.None); _name = string.Empty; _config = "{}"; + _modbusOptions = new(); _showForm = false; await ReloadAsync(); } catch (Exception ex) { _error = ex.Message; } } + + /// + /// Maps the view-model field names onto the JSON shape ModbusDriverFactoryExtensions + /// consumes. Hand-rolled because the DTO uses millisecond / byte field flavours that the + /// view model exposes as TimeSpan-derived integers; a System.Text.Json round-trip would + /// emit the .NET-native names instead. + /// + private static string SerializeModbusOptions(ModbusOptionsEditor.ModbusOptionsViewModel m) => + JsonSerializer.Serialize(new + { + host = m.Host, + port = m.Port, + unitId = m.UnitId, + family = m.Family.ToString(), + melsecSubFamily = m.MelsecSubFamily.ToString(), + keepAlive = new + { + enabled = m.KeepAliveEnabled, + timeMs = m.KeepAliveTimeSec * 1000, + intervalMs = m.KeepAliveIntervalSec * 1000, + retryCount = m.KeepAliveRetryCount, + }, + reconnect = new + { + initialDelayMs = m.ReconnectInitialDelayMs, + maxDelayMs = m.ReconnectMaxDelayMs, + backoffMultiplier = m.ReconnectBackoffMultiplier, + }, + maxRegistersPerRead = m.MaxRegistersPerRead, + maxRegistersPerWrite = m.MaxRegistersPerWrite, + maxCoilsPerRead = m.MaxCoilsPerRead, + maxReadGap = m.MaxReadGap, + useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites, + useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites, + writeOnChangeOnly = m.WriteOnChangeOnly, + tags = Array.Empty(), + }, ModbusJsonOptions); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs index 1079b61..59d3b92 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ModbusOptionsViewModelTests.cs @@ -14,6 +14,96 @@ namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; [Trait("Category", "Unit")] public sealed class ModbusOptionsViewModelTests { + [Fact] + public void DriversTab_Serialized_Defaults_RoundTrip_Through_Factory() + { + // #147 — the form's SaveAsync serialises ModbusOptionsViewModel to the JSON DTO + // shape ModbusDriverFactoryExtensions consumes. This test pins the round-trip: + // unedited form → JSON → driver instance → options match defaults. + var vm = new ModbusOptionsEditor.ModbusOptionsViewModel(); + var json = SerializeForRoundTrip(vm); + + var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-roundtrip", json); + var opts = (ModbusDriverOptions)typeof(ModbusDriver) + .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(driver)!; + + opts.Host.ShouldBe(vm.Host); + opts.Port.ShouldBe(vm.Port); + opts.UnitId.ShouldBe(vm.UnitId); + opts.Family.ShouldBe(vm.Family); + opts.MelsecSubFamily.ShouldBe(vm.MelsecSubFamily); + opts.KeepAlive.Enabled.ShouldBe(vm.KeepAliveEnabled); + opts.MaxRegistersPerRead.ShouldBe((ushort)vm.MaxRegistersPerRead); + opts.MaxCoilsPerRead.ShouldBe((ushort)vm.MaxCoilsPerRead); + opts.MaxReadGap.ShouldBe((ushort)vm.MaxReadGap); + opts.WriteOnChangeOnly.ShouldBe(vm.WriteOnChangeOnly); + } + + [Fact] + public void DriversTab_Serializes_Edited_Values_Correctly() + { + // Sanity: changing a few fields in the view model produces a JSON the factory + // accepts and the resulting driver carries the edited values. + var vm = new ModbusOptionsEditor.ModbusOptionsViewModel + { + Host = "10.5.5.5", + Port = 1502, + UnitId = 7, + Family = ModbusFamily.DL205, + MaxReadGap = 12, + WriteOnChangeOnly = true, + }; + var json = SerializeForRoundTrip(vm); + var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-edited", json); + var opts = (ModbusDriverOptions)typeof(ModbusDriver) + .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(driver)!; + + opts.Host.ShouldBe("10.5.5.5"); + opts.Port.ShouldBe(1502); + opts.UnitId.ShouldBe((byte)7); + opts.Family.ShouldBe(ModbusFamily.DL205); + opts.MaxReadGap.ShouldBe((ushort)12); + opts.WriteOnChangeOnly.ShouldBeTrue(); + } + + /// + /// Mirror of DriversTab.razor's SerializeModbusOptions — kept here so the test + /// doesn't have to reach through Blazor component plumbing to invoke it. If the + /// component method signature drifts, update both. + /// + private static string SerializeForRoundTrip(ModbusOptionsEditor.ModbusOptionsViewModel m) => + System.Text.Json.JsonSerializer.Serialize(new + { + host = m.Host, + port = m.Port, + unitId = m.UnitId, + family = m.Family.ToString(), + melsecSubFamily = m.MelsecSubFamily.ToString(), + keepAlive = new + { + enabled = m.KeepAliveEnabled, + timeMs = m.KeepAliveTimeSec * 1000, + intervalMs = m.KeepAliveIntervalSec * 1000, + retryCount = m.KeepAliveRetryCount, + }, + reconnect = new + { + initialDelayMs = m.ReconnectInitialDelayMs, + maxDelayMs = m.ReconnectMaxDelayMs, + backoffMultiplier = m.ReconnectBackoffMultiplier, + }, + maxRegistersPerRead = m.MaxRegistersPerRead, + maxRegistersPerWrite = m.MaxRegistersPerWrite, + maxCoilsPerRead = m.MaxCoilsPerRead, + maxReadGap = m.MaxReadGap, + useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites, + useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites, + writeOnChangeOnly = m.WriteOnChangeOnly, + tags = Array.Empty(), + }); + [Fact] public void Defaults_Match_DriverOption_Defaults() {