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

556 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```csharp
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**
```bash
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**
```csharp
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`:
```csharp
// 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;
```
2. 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;`):
```csharp
_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.
}
```
3. Make `BuildEditorParameters` **internal** and forward the live accessor:
```csharp
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**
```bash
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):
```csharp
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:
```csharp
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.
}
}
}
```
3. Forward the live accessor in `BuildEditorParameters(RowModel row)`:
```csharp
["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**
```bash
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**
```bash
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.