From a2093b440007ee443c795d8328b2dd6a7fade5b8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 5 Aug 2026 03:25:13 -0400 Subject: [PATCH] fix(raw): manual tag entry feeds each row's picker the merged device config (#468 follow-up) --- .../Shared/Raw/RawManualTagEntryModal.razor | 28 +++- ...RawManualTagEntryModalMergedConfigTests.cs | 132 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawManualTagEntryModalMergedConfigTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor index 3f4b5c9d..aa96d398 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor @@ -1,7 +1,8 @@ @* Bulk manual tag-entry grid for the v3 /raw tree: add MANY new tags at once under a Device or a TagGroup. Each row carries Name / DataType / AccessLevel / WriteIdempotent / PollGroup plus a per-row driver-typed TagConfig editor (revealed in an expandable full-width sub-row — the grid stays - compact). The owning DriverType (fixed by the device) drives the typed-editor dispatch. On commit each + compact), each fed the merged Driver+Device probe config so its address picker can browse the live + device. The owning DriverType (fixed by the device) drives the typed-editor dispatch. On commit each row is created via CreateTagAsync; committed rows drop off, failed rows stay with their inline error so nothing is lost silently. Visibility is @bind-Visible; a successful commit raises OnSaved. *@ @using Microsoft.AspNetCore.Components.Forms @@ -159,11 +160,17 @@ private bool _summaryOk; private bool _open; // guards re-seeding rows on unrelated re-renders - protected override void OnParametersSet() + // Merged Driver+Device config for each row's address picker — the same blob Test-connect and + // RawBrowseModal dial, resolved once per open; null (→ "{}") when the resolve failed: the picker's + // browse degrades to its graceful error chip. + private string? _mergedConfigJson; + + protected override async Task OnParametersSetAsync() { if (!Visible) { _open = false; + _mergedConfigJson = null; return; } if (_open) { return; } // same open, re-render → preserve in-progress rows @@ -172,6 +179,21 @@ _rows.Add(new RowModel()); _summary = null; _summaryOk = false; + + _mergedConfigJson = null; + if (!string.IsNullOrEmpty(DeviceId)) + { + try + { + var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId); + if (merged is { } m) { _mergedConfigJson = m.MergedConfigJson; } + } + catch + { + // Fail-soft: the grid must stay usable regardless — only the browse affordance inside each + // row's picker degrades, and it reports its own error on use. + } + } } private IDictionary BuildEditorParameters(RowModel row) => new Dictionary @@ -179,7 +201,7 @@ ["ConfigJson"] = row.TagConfig, ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => row.TagConfig = v), ["DriverType"] = DriverType, - ["GetDriverConfigJson"] = (Func)(() => "{}"), + ["GetDriverConfigJson"] = (Func)(() => _mergedConfigJson ?? "{}"), }; private void AddRow() => _rows.Add(new RowModel()); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawManualTagEntryModalMergedConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawManualTagEntryModalMergedConfigTests.cs new file mode 100644 index 00000000..b09d9f6a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawManualTagEntryModalMergedConfigTests.cs @@ -0,0 +1,132 @@ +using System.Reflection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw; + +/// +/// Pins that the bulk manual-entry grid hands each row's typed tag editor the merged +/// Driver+Device config, not a stub. +/// +/// +/// +/// The AbCip / TwinCAT / FOCAS editors' address pickers dial the GetDriverConfigJson Func as +/// their browse connect config. passed a literal "{}", +/// so "Browse controller" always connected against an empty config and could never reach the +/// device — the picker was authorable but unusable, and no offline test reached it (AdminUI has no +/// bUnit). +/// +/// +/// Unlike the single-tag modal, the device here is a direct [Parameter] — no tag load +/// precedes it — so the resolve keys off DeviceId on the open path. The accessor must stay +/// live (reading the field, not a captured snapshot), because the resolve is awaited after +/// the rows are seeded. Exercised as a plain class: AdminUI has no bUnit, so the injected service +/// and the lifecycle/parameter-building members are reached by reflection. +/// +/// +public sealed class RawManualTagEntryModalMergedConfigTests +{ + [Fact] + public async Task Each_rows_editor_receives_the_merged_device_config_not_a_stub() + { + const string merged = """{"endpoint":"10.0.0.1:44818","slot":0}"""; + + var svc = new FakeRawTreeService + { + OnLoadMergedProbeConfig = deviceId => + deviceId == "dev-1" ? ("AbCip", merged) : null, + }; + + var modal = new RawManualTagEntryModal(); + SetParameters(modal, visible: true, deviceId: "dev-1", driverType: "AbCip"); + InjectService(modal, svc); + + await OpenAsync(modal); + + GetDriverConfigJson(modal).ShouldBe(merged); + } + + [Fact] + public async Task A_failed_resolve_degrades_to_the_empty_stub_and_does_not_throw() + { + var svc = new FakeRawTreeService + { + OnLoadMergedProbeConfig = _ => throw new InvalidOperationException("device unreachable"), + }; + + var modal = new RawManualTagEntryModal(); + SetParameters(modal, visible: true, deviceId: "dev-1", driverType: "AbCip"); + InjectService(modal, svc); + + await Should.NotThrowAsync(() => OpenAsync(modal)); + + GetDriverConfigJson(modal).ShouldBe("{}"); + } + + /// + /// Sets the modal's [Parameter]s. By reflection because BL0005 forbids assigning component + /// parameters from outside the component, and this project treats warnings as errors. + /// + private static void SetParameters( + RawManualTagEntryModal modal, bool visible, string deviceId, string driverType) + { + Set("Visible", visible); + Set("DeviceId", deviceId); + Set("DriverType", driverType); + + void Set(string name, object value) + { + var property = typeof(RawManualTagEntryModal).GetProperty(name); + property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed."); + property!.SetValue(modal, value); + } + } + + /// Sets the @inject-generated private Svc property. + private static void InjectService(RawManualTagEntryModal modal, FakeRawTreeService svc) + { + var property = typeof(RawManualTagEntryModal).GetProperty( + "Svc", + BindingFlags.NonPublic | BindingFlags.Instance); + + property.ShouldNotBeNull("the injected IRawTreeService property is no longer named Svc."); + + property!.SetValue(modal, svc); + } + + /// Drives the open path — the protected lifecycle method the resolve hangs off. + private static async Task OpenAsync(RawManualTagEntryModal modal) + { + var method = typeof(RawManualTagEntryModal).GetMethod( + "OnParametersSetAsync", + BindingFlags.NonPublic | BindingFlags.Instance); + + method.ShouldNotBeNull("the open path moved off OnParametersSetAsync — retarget this driver."); + + await (Task)method!.Invoke(modal, null)!; + } + + /// Invokes the GetDriverConfigJson Func the first seeded row's editor is handed. + private static string GetDriverConfigJson(RawManualTagEntryModal modal) + { + var rowsField = typeof(RawManualTagEntryModal).GetField( + "_rows", + BindingFlags.NonPublic | BindingFlags.Instance); + + rowsField.ShouldNotBeNull("the manual-entry grid's row list is no longer named _rows."); + + var rows = ((System.Collections.IEnumerable)rowsField!.GetValue(modal)!).Cast().ToList(); + rows.ShouldNotBeEmpty("the open path no longer seeds a row, so no editor parameters exist to check."); + + var build = typeof(RawManualTagEntryModal).GetMethod( + "BuildEditorParameters", + BindingFlags.NonPublic | BindingFlags.Instance); + + build.ShouldNotBeNull("BuildEditorParameters was renamed — this test reads it by name."); + + var parameters = (IDictionary)build!.Invoke(modal, [rows[0]])!; + + return ((Func)parameters["GetDriverConfigJson"])(); + } +}