Files
lmxopcua/docs/plans/2026-08-05-raw-modal-picker-live-config.md
T

23 KiB
Raw Blame History

Raw-modal picker live config — implementation plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Replace the () => "{}" stub GetDriverConfigJson in RawTagModal and RawManualTagEntryModal with the merged Driver+Device config, so the AbCip/TwinCAT/FOCAS address pickers' "Browse controller" button captures against the real device.

Architecture: Each modal resolves IRawTreeService.LoadMergedProbeConfigAsync(deviceId) once per open (the same merge Test-connect and RawBrowseModal dial), caches the JSON in a private field, and passes a live Func<string> that reads the cache with a "{}" fallback. Fail-soft: a failed resolve leaves the stub behaviour (graceful error chip in the picker), never blocks the modal. Design: docs/plans/2026-08-05-raw-modal-picker-live-config-design.md.

Tech Stack: Blazor Server (razor component classes tested directly — no bUnit; the AdminUI csproj has InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.AdminUI.Tests")), xUnit + Shouldly, hand-written fakes (no mocking library in the test project).

Key facts for the implementer (zero-context):

  • The two stubs are at RawTagModal.razor BuildEditorParameters() (~line 190) and RawManualTagEntryModal.razor BuildEditorParameters(RowModel) (~line 182): ["GetDriverConfigJson"] = (Func<string>)(() => "{}").
  • Both modals already @inject IRawTreeService Svc. The resolver exists: Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(string deviceId, CancellationToken ct = default) (src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs:396).
  • RawTagModal learns its device from RawTagEditDto.DeviceId (returned by Svc.LoadTagForEditAsync(TagId) in OnParametersSetAsync). RawManualTagEntryModal takes DeviceId as a [Parameter].
  • The Func<string> MUST stay a live accessor (read the field at call time, not capture a value): the picker body (AbCipAddressPickerBody.OpenBrowseAsync) calls it at click time, which is what makes intermediate renders during the async resolve harmless.
  • Both modals guard against re-render re-initialisation (_loadedKey / _open) — the resolve must ride the same guard so an in-progress edit is never clobbered by a re-resolve.
  • Test precedent for reaching into razor component classes: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawDriverTypeDialogParityTests.cs (reflection; razor components are ordinary classes in ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw). @inject generates a private Svc property — set it with reflection (BindingFlags.NonPublic | BindingFlags.Instance). OnParametersSetAsync is protected — invoke via reflection. [Parameter] properties are public — set directly.
  • Repo conventions: xUnit + Shouldly, sentence-style test names (The_editor_receives_the_merged_config), comments state constraints not narration.

Task 0: FakeRawTreeService test helper

Classification: small Estimated implement time: ~4 min Parallelizable with: none (Tasks 1–2 depend on it)

Files:

  • Create: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/FakeRawTreeService.cs
  • Read (reference only): src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs

Step 1: Write the fake

A hand-written fake implementing IRawTreeService (the repo has no mocking library). Two members are driven by settable delegates; every other interface member throws NotImplementedException (one-liners — generate them from the interface file):

using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;

/// <summary>
///     Minimal <see cref="IRawTreeService"/> fake for component-class tests. Only the members the
///     raw modals' open path touches are configurable; everything else throws so an unexpected
///     call fails the test instead of vacuously passing.
/// </summary>
internal sealed class FakeRawTreeService : IRawTreeService
{
    public Func<string, RawTagEditDto?> OnLoadTagForEdit { get; set; } =
        _ => throw new NotImplementedException();

    public Func<string, (string DriverType, string MergedConfigJson)?> OnLoadMergedProbeConfig { get; set; } =
        _ => throw new NotImplementedException();

    public Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default)
        => Task.FromResult(OnLoadTagForEdit(tagId));

    public Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(
        string deviceId, CancellationToken ct = default)
        => Task.FromResult(OnLoadMergedProbeConfig(deviceId));

    // --- every other IRawTreeService member: throw new NotImplementedException(); ---
}

Implement the remaining members exactly as declared in IRawTreeService.cs (expression-bodied => throw new NotImplementedException();). Do not add behaviour to any of them.

Step 2: Compile the test project

Run: dotnet build tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests Expected: 0 errors.

Step 3: Commit

git add tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/FakeRawTreeService.cs
git commit -m "test(raw): FakeRawTreeService for raw-modal component tests"

Task 1: RawTagModal — resolve and forward the merged config

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor (the @code block: OnParametersSetAsync ~lines 143–183, BuildEditorParameters ~lines 185–191)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs (create)

Step 1: Write the failing tests

using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;

/// <summary>
///     Pins the #468 follow-up fix: the typed tag editor inside the raw tag edit modal must
///     receive the merged Driver+Device config — not the historical "{}" stub — or the
///     AbCip/TwinCAT/FOCAS pickers' "Browse controller" captures against no endpoint and can
///     never succeed. The forwarding is razor wiring no rendered-output test reaches (no bUnit),
///     so this drives the component class directly, as RawDriverTypeDialogParityTests does.
/// </summary>
public sealed class RawTagModalMergedConfigTests
{
    private const string Merged = /*lang=json*/ """{"endpoint":"10.0.0.1:44818","slot":0}""";

    private static RawTagEditDto Dto(string deviceId) => new(
        "tag-1", deviceId, null, "T1", "Float",
        default, false, null, "{}", Array.Empty<byte>());

    private static void InjectSvc(RawTagModal modal, IRawTreeService svc)
    {
        var p = typeof(RawTagModal).GetProperty("Svc",
            BindingFlags.NonPublic | BindingFlags.Instance);
        p.ShouldNotBeNull("RawTagModal's @inject property was renamed — this test sets it by name.");
        p!.SetValue(modal, svc);
    }

    private static Task OpenAsync(RawTagModal modal)
    {
        var m = typeof(RawTagModal).GetMethod("OnParametersSetAsync",
            BindingFlags.NonPublic | BindingFlags.Instance);
        m.ShouldNotBeNull();
        return (Task)m!.Invoke(modal, null)!;
    }

    [Fact]
    public async Task The_editor_receives_the_merged_device_config_not_a_stub()
    {
        var modal = new RawTagModal { Visible = true, TagId = "tag-1", DriverType = "AbCip" };
        InjectSvc(modal, new FakeRawTreeService
        {
            OnLoadTagForEdit = _ => Dto("dev-1"),
            OnLoadMergedProbeConfig = id => id == "dev-1" ? ("AbCip", Merged) : null,
        });

        await OpenAsync(modal);

        var func = (Func<string>)modal.BuildEditorParameters()["GetDriverConfigJson"];
        func().ShouldBe(Merged);
    }

    [Fact]
    public async Task A_failed_resolve_degrades_to_the_empty_stub_and_does_not_throw()
    {
        var modal = new RawTagModal { Visible = true, TagId = "tag-1", DriverType = "AbCip" };
        InjectSvc(modal, new FakeRawTreeService
        {
            OnLoadTagForEdit = _ => Dto("dev-1"),
            OnLoadMergedProbeConfig = _ => throw new InvalidOperationException("device store down"),
        });

        await OpenAsync(modal);   // must not throw — fail-soft is the design (§3)

        var func = (Func<string>)modal.BuildEditorParameters()["GetDriverConfigJson"];
        func().ShouldBe("{}");
    }

    [Fact]
    public async Task The_accessor_is_live_not_a_captured_snapshot()
    {
        // The picker body reads the Func at click time; intermediate renders during the async
        // resolve are only harmless because the accessor reads the field, not a captured value.
        var modal = new RawTagModal { Visible = true, TagId = "tag-1", DriverType = "AbCip" };
        var svc = new FakeRawTreeService
        {
            OnLoadTagForEdit = _ => Dto("dev-1"),
            OnLoadMergedProbeConfig = _ => null,   // first open resolves nothing
        };
        InjectSvc(modal, svc);
        await OpenAsync(modal);
        var func = (Func<string>)modal.BuildEditorParameters()["GetDriverConfigJson"];
        func().ShouldBe("{}");

        // Re-open for a different tag with the store now answering.
        modal.Visible = false;
        await OpenAsync(modal);
        modal.Visible = true;
        modal.TagId = "tag-2";
        svc.OnLoadTagForEdit = _ => Dto("dev-1") with { TagId = "tag-2" };
        svc.OnLoadMergedProbeConfig = _ => ("AbCip", Merged);
        await OpenAsync(modal);

        func = (Func<string>)modal.BuildEditorParameters()["GetDriverConfigJson"];
        func().ShouldBe(Merged);
    }
}

Notes for the implementer:

  • RawTagEditDto is a readonly record struct in ZB.MOM.WW.OtOpcUa.AdminUI.Uns — check its ctor order in IRawTreeService.cs:20 and the AccessLevel parameter's enum type/namespace (use default for it as above if the name differs).
  • If BuildEditorParameters is private today, Step 3 makes it internal — the test compiles against InternalsVisibleTo.

Step 2: Run tests to verify they fail

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~RawTagModalMergedConfigTests" Expected: FAIL — BuildEditorParameters inaccessible (compile) first; after making it internal (do that now if needed to get a red run), the first and third tests fail with "{}" vs merged. The stub must be observed failing before Step 3's wiring lands.

Step 3: Implement

In RawTagModal.razor @code:

  1. Add the cache field next to _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;
  1. In OnParametersSetAsync, clear it in the !Visible branch (_mergedConfigJson = null; next to _loadedKey = null;), and resolve it after the dto load succeeds (after _rowVersion = d.RowVersion;):
        _mergedConfigJson = null;
        try
        {
            var merged = await Svc.LoadMergedProbeConfigAsync(d.DeviceId);
            if (merged is { } m) { _mergedConfigJson = m.MergedConfigJson; }
        }
        catch
        {
            // Fail-soft by design: the modal must open and save regardless — only the browse
            // affordance inside the picker is degraded, and it reports its own error on use.
        }
  1. Make BuildEditorParameters internal and forward the live accessor:
    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>)(() => _mergedConfigJson ?? "{}"),
    };

Also update the modal's header comment (lines 1–6) to mention that the editor receives the merged probe config for browse.

Step 4: Run tests to verify they pass

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~RawTagModalMergedConfigTests" Expected: 3 passed.

Step 5: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs
git commit -m "fix(raw): tag edit modal feeds the picker the merged device config, not '{}' (#468 follow-up)"

Task 2: RawManualTagEntryModal — resolve and forward the merged config

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor (the @code block: OnParametersSet ~lines 162–175, BuildEditorParameters ~lines 177–183)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawManualTagEntryModalMergedConfigTests.cs (create)

Step 1: Write the failing tests

Same shape as Task 1 (reuse its reflection helpers, adapted to this component — duplicate the two small private helpers rather than building a shared abstraction; two call sites do not earn one):

using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;

/// <summary>
///     Same defect, same pin as <see cref="RawTagModalMergedConfigTests"/>, for the bulk
///     manual-entry grid: each row's typed editor must receive the merged Driver+Device config.
///     Unlike the edit modal, the device is a direct parameter here — no tag load precedes it.
/// </summary>
public sealed class RawManualTagEntryModalMergedConfigTests
{
    private const string Merged = /*lang=json*/ """{"endpoint":"10.0.0.1:44818","slot":0}""";

    private static void InjectSvc(RawManualTagEntryModal modal, IRawTreeService svc)
    {
        var p = typeof(RawManualTagEntryModal).GetProperty("Svc",
            BindingFlags.NonPublic | BindingFlags.Instance);
        p.ShouldNotBeNull("RawManualTagEntryModal's @inject property was renamed — this test sets it by name.");
        p!.SetValue(modal, svc);
    }

    private static Task OpenAsync(RawManualTagEntryModal modal)
    {
        var m = typeof(RawManualTagEntryModal).GetMethod("OnParametersSetAsync",
            BindingFlags.NonPublic | BindingFlags.Instance);
        m.ShouldNotBeNull("the open path moved off OnParametersSetAsync — retarget this driver.");
        return (Task)m!.Invoke(modal, null)!;
    }

    private static Func<string> EditorConfigAccessor(RawManualTagEntryModal modal)
    {
        // BuildEditorParameters takes the private RowModel — go through it by reflection with the
        // first seeded row, exactly as the razor markup does per row.
        var rows = typeof(RawManualTagEntryModal)
            .GetField("_rows", BindingFlags.NonPublic | BindingFlags.Instance)!
            .GetValue(modal)!;
        var row = ((System.Collections.IEnumerable)rows).Cast<object>().First();
        var build = typeof(RawManualTagEntryModal).GetMethod("BuildEditorParameters",
            BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
        build.ShouldNotBeNull();
        var dict = (IDictionary<string, object>)build!.Invoke(modal, new[] { row })!;
        return (Func<string>)dict["GetDriverConfigJson"];
    }

    [Fact]
    public async Task Each_rows_editor_receives_the_merged_device_config_not_a_stub()
    {
        var modal = new RawManualTagEntryModal { Visible = true, DeviceId = "dev-1", DriverType = "AbCip" };
        InjectSvc(modal, new FakeRawTreeService
        {
            OnLoadMergedProbeConfig = id => id == "dev-1" ? ("AbCip", Merged) : null,
        });

        await OpenAsync(modal);

        EditorConfigAccessor(modal)().ShouldBe(Merged);
    }

    [Fact]
    public async Task A_failed_resolve_degrades_to_the_empty_stub_and_does_not_throw()
    {
        var modal = new RawManualTagEntryModal { Visible = true, DeviceId = "dev-1", DriverType = "AbCip" };
        InjectSvc(modal, new FakeRawTreeService
        {
            OnLoadMergedProbeConfig = _ => throw new InvalidOperationException("device store down"),
        });

        await OpenAsync(modal);   // must not throw — fail-soft is the design (§3)

        EditorConfigAccessor(modal)().ShouldBe("{}");
    }
}

Step 2: Run tests to verify they fail

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~RawManualTagEntryModalMergedConfigTests" Expected: FAIL — OnParametersSetAsync does not exist yet (the component overrides the sync OnParametersSet), so the OpenAsync helper's ShouldNotBeNull trips. That IS the red state.

Step 3: Implement

In RawManualTagEntryModal.razor @code:

  1. Add the same cache field as Task 1 (same comment).
  2. Convert OnParametersSet to OnParametersSetAsync, preserving the _open guard and row seeding exactly, and appending the resolve:
    protected override async Task OnParametersSetAsync()
    {
        if (!Visible)
        {
            _open = false;
            _mergedConfigJson = null;
            return;
        }
        if (_open) { return; }   // same open, re-render → preserve in-progress rows
        _open = true;
        _rows.Clear();
        _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 by design: the grid must stay usable regardless — only the browse
                // affordance inside each row's picker is degraded, and it reports its own error.
            }
        }
    }
  1. Forward the live accessor in BuildEditorParameters(RowModel row):
        ["GetDriverConfigJson"] = (Func<string>)(() => _mergedConfigJson ?? "{}"),

Update the header comment as in Task 1.

Step 4: Run tests to verify they pass

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~RawManualTagEntryModalMergedConfigTests" Expected: 2 passed.

Step 5: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawManualTagEntryModal.razor \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawManualTagEntryModalMergedConfigTests.cs
git commit -m "fix(raw): manual tag entry feeds each row's picker the merged device config (#468 follow-up)"

Task 3: Full-suite verification

Classification: small Estimated implement time: ~4 min (mostly wall-clock on the suite) Parallelizable with: none (needs Tasks 1–2 merged in the worktree)

Files: none new — verification only.

Step 1: Build the full solution

Run: dotnet build ZB.MOM.WW.OtOpcUa.slnx Expected: 0 errors.

Step 2: Run the AdminUI test project

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests Expected: all passed, 0 failed (baseline before this work: 749+; now +5).

Step 3: Commit anything outstanding

Nothing should be outstanding; if a fix was needed to get green, commit it with an explanatory message. Do NOT commit a red state.


Task 4: Documentation bookkeeping

Classification: trivial Estimated implement time: ~3 min Parallelizable with: none

Files:

  • Modify: docs/plans/2026-07-24-driver-expansion-tracking.md (Wave 0 section, "Residual follow-up" bullet)
  • Modify: docs/plans/2026-07-15-universal-discovery-browser-implementation.md ("Follow-ups discovered during the first gate" item 2)
  • Modify: docs/plans/2026-08-05-raw-modal-picker-live-config-design.md (Status line)

Step 1: Update all three

  • Tracking doc: rewrite the "Residual follow-up (open)" bullet as closed — the named *DriverPage shells were already deleted by v3 Batch 2; the surviving instance (the raw modals' "{}" stubs) is fixed by this change (name the commits), unit-pinned by RawTagModalMergedConfigTests / RawManualTagEntryModalMergedConfigTests, live gate per the design doc §4.
  • Universal-browser implementation plan: mark first-gate follow-up 2 closed with the same summary (strikethrough + CLOSED note, matching the doc's existing style for items 1/2 of the second gate).
  • Design doc: Status → implemented, tests green, live gate pending/PASSED as applicable.

Step 2: Commit

git add docs/plans/2026-07-24-driver-expansion-tracking.md \
        docs/plans/2026-07-15-universal-discovery-browser-implementation.md \
        docs/plans/2026-08-05-raw-modal-picker-live-config-design.md
git commit -m "docs(raw): close the #468 follow-up — raw modals now feed pickers live merged config"

Task 5: Live /run gate (main session — NOT a subagent task)

Classification: high-risk (it is the class of verification unit tests cannot provide here) Estimated implement time: ~15 min wall-clock Parallelizable with: none

This is executed by the main session after Tasks 0–4 merge, using the Chrome automation tools against the local docker-dev rig (login disabled — no sign-in needed) and the patched AbCip fixture.

Recipe:

  1. Rig up (OrbStack local): docker-dev/docker-compose.yml stack; AdminUI at http://localhost:9200. AbCip fixture on the shared host: ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d' (endpoint 10.100.0.35:44818 — the #468 second-gate patched ab_server serving @tags).
  2. In /raw, on an AbCip device pointed at the fixture with at least one authored tag:
    • Edit modal leg: open the tag's edit modal → the AbCip editor's "Browse controller" → expect the nine-tag tree (Motor_Run … TestDINT), not an error chip. Select a leaf, confirm the address lands in the Tag-name field.
    • Manual entry leg: device context menu → Add tags (manual) → expand a row's config editor → "Browse controller" → same nine-tag tree.
    • Negative leg: on a device with an unreachable endpoint, the button still renders and the click yields the graceful error chip; the modal stays usable and can still save.
  3. Record the result in the design doc §4 (and the tracking doc bullet from Task 4).

Execution notes

  • Worktree: run via superpowers-extended-cc:subagent-driven-development in a fresh worktree (Tasks 1 and 2 may dispatch in parallel after Task 0; Tasks 3 → 4 serial after them).
  • Subagent model: opus (user-specified).
  • The Files: blocks are the files_to_edit contract; needing other files is a plan defect — surface it.