fix(raw): tag edit modal feeds the picker the merged device config, not '{}' (#468 follow-up)

This commit is contained in:
Joseph Doherty
2026-08-05 03:25:13 -04:00
parent e698aee401
commit abb9f8a1cb
2 changed files with 167 additions and 3 deletions
@@ -3,7 +3,8 @@
passed in and drives the typed TagConfig editor dispatch (TagConfigEditorMap; raw-JSON fallback for
unmapped drivers, e.g. Galaxy). Edit-only: the host opens it for an existing tree node (TagId), it
loads via LoadTagForEditAsync, and saves via UpdateTagAsync. Visibility is @bind-Visible; a successful
save raises OnSaved so the coordinator can refresh the tree. *@
save raises OnSaved so the coordinator can refresh the tree. The editor also receives the merged
Driver+Device probe config, which is what its address picker dials when browsing the live device. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@@ -140,11 +141,17 @@
// reload + clobber in-progress edits. Null while closed.
private string? _loadedKey;
// Merged Driver+Device config for the typed editor's address picker — the same blob Test-connect and
// RawBrowseModal dial, resolved once per open. Null (→ "{}") when the resolve failed: the picker's
// browse then degrades to its graceful error chip.
private string? _mergedConfigJson;
protected override async Task OnParametersSetAsync()
{
if (!Visible)
{
_loadedKey = null; // closed → next open reloads fresh
_mergedConfigJson = null;
return;
}
@@ -180,14 +187,31 @@
TagConfig = string.IsNullOrWhiteSpace(d.TagConfig) ? "{}" : d.TagConfig,
};
_rowVersion = d.RowVersion;
_mergedConfigJson = null;
try
{
var merged = await Svc.LoadMergedProbeConfigAsync(d.DeviceId);
if (merged is { } m)
{
_mergedConfigJson = m.MergedConfigJson;
}
}
catch
{
// Fail-soft: the modal must open and save regardless of the probe-config resolve. Only the
// browse affordance degrades, and it reports its own error when used.
}
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
/// <summary>Parameters handed to the typed TagConfig editor. Internal so tests can assert them.</summary>
internal IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = DriverType ?? "",
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
// Live accessor, not a snapshot: the picker reads it when the operator clicks Browse.
["GetDriverConfigJson"] = (Func<string>)(() => _mergedConfigJson ?? "{}"),
};
private async Task SaveAsync()
@@ -0,0 +1,140 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>
/// Pins the #468 follow-up: the <c>/raw</c> tag edit modal must hand its typed TagConfig editor the
/// merged Driver+Device config, not the historical <c>"{}"</c> stub.
/// </summary>
/// <remarks>
/// <para>
/// The AbCip / TwinCAT / FOCAS tag editors embed an address picker whose "Browse controller"
/// affordance dials the <c>GetDriverConfigJson</c> accessor as its connect config. While that
/// accessor returned a literal <c>"{}"</c> the browse captured against an empty config and could
/// never succeed — a defect no offline test could see, because the accessor was well-formed JSON.
/// </para>
/// <para>
/// AdminUI has no bUnit, so the component is driven directly as a class: the injected
/// <c>Svc</c> property and the protected <c>OnParametersSetAsync</c> lifecycle hook are reached by
/// reflection, and the editor parameters are read back through the internal
/// <c>BuildEditorParameters</c>.
/// </para>
/// </remarks>
// BL0005 forbids setting a [Parameter] from outside the component, which is right for production code
// and impossible to honour here: with no bUnit there is no renderer to supply parameters, so driving the
// component as a class means assigning them directly.
#pragma warning disable BL0005
public sealed class RawTagModalMergedConfigTests
{
private const string MergedConfig = """{"endpoint":"10.0.0.1:44818","slot":0}""";
[Fact]
public async Task The_editor_receives_the_merged_device_config_not_a_stub()
{
var svc = new FakeRawTreeService
{
OnLoadTagForEdit = tagId => TagOn(tagId, "dev-1"),
OnLoadMergedProbeConfig = deviceId =>
deviceId == "dev-1" ? ("AbCip", MergedConfig) : null,
};
var modal = NewModal(svc, "tag-1");
await InvokeParametersSetAsync(modal);
DriverConfigJson(modal).ShouldBe(MergedConfig);
}
[Fact]
public async Task A_failed_resolve_degrades_to_the_empty_stub_and_does_not_throw()
{
var svc = new FakeRawTreeService
{
OnLoadTagForEdit = tagId => TagOn(tagId, "dev-1"),
OnLoadMergedProbeConfig = _ => throw new InvalidOperationException("device is gone"),
};
var modal = NewModal(svc, "tag-1");
// The modal must still open and remain saveable — only the browse affordance degrades, and it
// reports its own error when used.
await Should.NotThrowAsync(() => InvokeParametersSetAsync(modal));
DriverConfigJson(modal).ShouldBe("{}");
}
[Fact]
public async Task The_accessor_is_live_not_a_captured_snapshot()
{
var svc = new FakeRawTreeService
{
OnLoadTagForEdit = tagId => TagOn(tagId, tagId == "tag-1" ? "dev-1" : "dev-2"),
OnLoadMergedProbeConfig = _ => null,
};
var modal = NewModal(svc, "tag-1");
await InvokeParametersSetAsync(modal);
var firstAccessor = (Func<string>)modal.BuildEditorParameters()["GetDriverConfigJson"];
firstAccessor().ShouldBe("{}");
// Close, then reopen on a different tag whose device now resolves. The per-open cache must have
// been reset and re-resolved, and the accessor must read the field at call time rather than
// having closed over the value it saw when it was built.
modal.Visible = false;
await InvokeParametersSetAsync(modal);
svc.OnLoadMergedProbeConfig = deviceId =>
deviceId == "dev-2" ? ("AbCip", MergedConfig) : null;
modal.Visible = true;
modal.TagId = "tag-2";
await InvokeParametersSetAsync(modal);
DriverConfigJson(modal).ShouldBe(MergedConfig);
firstAccessor().ShouldBe(MergedConfig);
}
private static RawTagEditDto TagOn(string tagId, string deviceId) => new(
tagId,
deviceId,
TagGroupId: null,
Name: "Speed",
DataType: "Float",
AccessLevel: TagAccessLevel.Read,
WriteIdempotent: false,
PollGroupId: null,
TagConfig: "{}",
RowVersion: []);
private static RawTagModal NewModal(IRawTreeService svc, string tagId)
{
var modal = new RawTagModal { Visible = true, TagId = tagId, DriverType = "AbCip" };
// @inject generates a private property, so the fake goes in by reflection.
var injected = typeof(RawTagModal).GetProperty(
"Svc",
BindingFlags.NonPublic | BindingFlags.Instance);
injected.ShouldNotBeNull("RawTagModal no longer injects IRawTreeService as 'Svc'.");
injected!.SetValue(modal, svc);
return modal;
}
private static Task InvokeParametersSetAsync(RawTagModal modal)
{
var hook = typeof(RawTagModal).GetMethod(
"OnParametersSetAsync",
BindingFlags.NonPublic | BindingFlags.Instance);
hook.ShouldNotBeNull("RawTagModal no longer overrides OnParametersSetAsync.");
return (Task)hook!.Invoke(modal, null)!;
}
private static string DriverConfigJson(RawTagModal modal)
=> ((Func<string>)modal.BuildEditorParameters()["GetDriverConfigJson"])();
}
#pragma warning restore BL0005