fix(raw): manual tag entry feeds each row's picker the merged device config (#468 follow-up)

This commit is contained in:
Joseph Doherty
2026-08-05 03:25:13 -04:00
parent abb9f8a1cb
commit a2093b4400
2 changed files with 157 additions and 3 deletions
@@ -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<string, object> BuildEditorParameters(RowModel row) => new Dictionary<string, object>
@@ -179,7 +201,7 @@
["ConfigJson"] = row.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => row.TagConfig = v),
["DriverType"] = DriverType,
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
["GetDriverConfigJson"] = (Func<string>)(() => _mergedConfigJson ?? "{}"),
};
private void AddRow() => _rows.Add(new RowModel());
@@ -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;
/// <summary>
/// Pins that the bulk manual-entry grid hands each row's typed tag editor the <b>merged</b>
/// Driver+Device config, not a stub.
/// </summary>
/// <remarks>
/// <para>
/// The AbCip / TwinCAT / FOCAS editors' address pickers dial the <c>GetDriverConfigJson</c> Func as
/// their browse connect config. <see cref="RawManualTagEntryModal"/> passed a literal <c>"{}"</c>,
/// 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).
/// </para>
/// <para>
/// Unlike the single-tag modal, the device here is a direct <c>[Parameter]</c> — no tag load
/// precedes it — so the resolve keys off <c>DeviceId</c> on the open path. The accessor must stay
/// <b>live</b> (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.
/// </para>
/// </remarks>
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("{}");
}
/// <summary>
/// Sets the modal's <c>[Parameter]</c>s. By reflection because BL0005 forbids assigning component
/// parameters from outside the component, and this project treats warnings as errors.
/// </summary>
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);
}
}
/// <summary>Sets the <c>@inject</c>-generated private <c>Svc</c> property.</summary>
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);
}
/// <summary>Drives the open path — the protected lifecycle method the resolve hangs off.</summary>
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)!;
}
/// <summary>Invokes the <c>GetDriverConfigJson</c> Func the first seeded row's editor is handed.</summary>
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<object>().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<string, object>)build!.Invoke(modal, [rows[0]])!;
return ((Func<string>)parameters["GetDriverConfigJson"])();
}
}