diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor index 4f6ed3f2..8e6ad6e1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor @@ -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 BuildEditorParameters() => new Dictionary + /// Parameters handed to the typed TagConfig editor. Internal so tests can assert them. + internal IDictionary BuildEditorParameters() => new Dictionary { ["ConfigJson"] = _form.TagConfig, ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v), ["DriverType"] = DriverType ?? "", - ["GetDriverConfigJson"] = (Func)(() => "{}"), + // Live accessor, not a snapshot: the picker reads it when the operator clicks Browse. + ["GetDriverConfigJson"] = (Func)(() => _mergedConfigJson ?? "{}"), }; private async Task SaveAsync() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs new file mode 100644 index 00000000..3ca5dd50 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs @@ -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; + +/// +/// Pins the #468 follow-up: the /raw tag edit modal must hand its typed TagConfig editor the +/// merged Driver+Device config, not the historical "{}" stub. +/// +/// +/// +/// The AbCip / TwinCAT / FOCAS tag editors embed an address picker whose "Browse controller" +/// affordance dials the GetDriverConfigJson accessor as its connect config. While that +/// accessor returned a literal "{}" 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. +/// +/// +/// AdminUI has no bUnit, so the component is driven directly as a class: the injected +/// Svc property and the protected OnParametersSetAsync lifecycle hook are reached by +/// reflection, and the editor parameters are read back through the internal +/// BuildEditorParameters. +/// +/// +// 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)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)modal.BuildEditorParameters()["GetDriverConfigJson"])(); +} +#pragma warning restore BL0005