Compare commits
13 Commits
3c6607d734
...
de036c62aa
| Author | SHA1 | Date | |
|---|---|---|---|
| de036c62aa | |||
| 8af5742406 | |||
| d88e42b8c4 | |||
| 4b88dc6f9a | |||
| c1e8762cd4 | |||
| 2c19a50bf9 | |||
| 4e3a6e8497 | |||
| c1c21f3ff8 | |||
| a2093b4400 | |||
| abb9f8a1cb | |||
| e698aee401 | |||
| ebcf73f72c | |||
| 3b9009da47 |
@@ -60,6 +60,7 @@ So everything below is **untracked work**. Nothing in it is a data-loss or outag
|
||||
| **1 genuinely unexecuted plan** — `2026-05-29-adminui-followups`, **19 pending / 0 completed** | The only substantial engineering left. ⚠️ Its first step is an **overlap audit**, not code: several items look superseded by v3's `TagConfigEditorMap`. | §6.2 |
|
||||
| **11 other open plan tasks** — 5 akka-hosting partials (likely v3-superseded), 4+1 followups-batch (open *by choice*), fixedtree Task 11, 1 manual smoke checklist | Small, and two of the groups are decisions already taken. | §6.2 |
|
||||
| **Review-coverage gap** — 10 shipped projects never reviewed (Calculation, Historian.Gateway, Mqtt +Browser/Contracts, MTConnect +Contracts, Sql +Browser/Contracts) while **3 modules still review the retired Wonderware backend** | Re-verified 2026-08-04 against `code-reviews/`. Unchanged since the audit. | §6.3 |
|
||||
| **`RawBrowseModal` stale-open race** (added 2026-08-05) — `OnParametersSetAsync` never re-checks staleness after its awaits, so a superseded open's late resolve can install the wrong device's browse config. The identical shape was found by review and **fixed** in `RawTagModal`/`RawManualTagEntryModal` (post-await staleness re-check; see `docs/plans/2026-08-05-raw-modal-picker-live-config-design.md` §6); the browse modal was deliberately left out of that branch's scope. | Lower stakes than the fixed pair (the modal resolves the device it was opened for and shows its own error banner), but the fix pattern is proven and cheap to port. | design doc §6 |
|
||||
|
||||
⚠️ **One correction worth carrying forward.** archreview **03/S4** — "primary gate default-allows on
|
||||
unknown role" — is the *same shape* as a decision deliberately taken on 2026-08-04 in
|
||||
|
||||
@@ -217,9 +217,10 @@ MELSEC is out of the sequence (deferred).
|
||||
|
||||
## 9. Status
|
||||
|
||||
Research: **done** (8 reports). Designs: **done** (7 design docs). Code: **Wave 0 landed** —
|
||||
the universal Discover-backed browser (P1) is code-complete and unit-tested on
|
||||
`feat/universal-discovery-browser` (`CapturingAddressSpaceBuilder` + `CapturedTreeBrowseSession`
|
||||
+ `DiscoveryDriverBrowser`/`IUniversalDriverBrowser` + `BrowserSessionService` fallback +
|
||||
`ITagDiscovery.SupportsOnlineDiscovery` gate + AbCip/TwinCAT/FOCAS opt-ins & picker browse UI),
|
||||
with the live `/run` gate outstanding. Next action: complete the Wave-0 live gate, then Wave 1.
|
||||
Research: **done** (8 reports). Designs: **done** (7 design docs). Code: **Waves 0–2 all
|
||||
shipped and live-gated** (as of 2026-08-01) — the universal browser (merged `056887d6`; live
|
||||
gate Gitea #468 CLOSED 2026-08-01 via the patched AbCip fixture, `c2c3c522`), Modbus RTU
|
||||
(`0f38f486`/#495), SQL poll (`4ad54037`), MTConnect Agent (#506), and MQTT/Sparkplug B (P1+P2).
|
||||
Per-deliverable detail: [`2026-07-24-driver-expansion-tracking.md`](2026-07-24-driver-expansion-tracking.md).
|
||||
Next action: nothing scheduled — Wave 3 (BACnet/IP, Omron) has designs but no implementation
|
||||
plans; MELSEC stays deferred (§1).
|
||||
|
||||
@@ -1154,11 +1154,16 @@ neither agreed with a controller. Fixed, with the layout now pinned by a capture
|
||||
### Follow-ups discovered during the first gate
|
||||
|
||||
1. ~~**Full tree-render verification is fixture-blocked.**~~ **CLOSED** by the second gate above.
|
||||
2. **Driver-page address pickers pass no live config.** `AbCipDriverPage`/`TwinCATDriverPage`/`FOCASDriverPage`
|
||||
host the same `*AddressPickerBody` but do **not** pass `DriverType`/`GetConfigJson`, so their (now-visible)
|
||||
Browse button would capture against `"{}"` (no device). Out of Task 14's scope (which listed only the
|
||||
TagEditor pass-through); wire the live form config through there too, or hide the Browse affordance on the
|
||||
driver-config picker.
|
||||
2. ~~**Driver-page address pickers pass no live config.**~~ — **CLOSED 2026-08-05.** The named
|
||||
`*DriverPage` shells were deleted by v3 Batch 2 before this was ever actioned, but the defect
|
||||
pattern survived into the v3 tag-editor hosts (`RawTagModal` / `RawManualTagEntryModal`, both
|
||||
passing a `"{}"` stub as `GetDriverConfigJson`). Fixed by resolving the merged Driver+Device
|
||||
config (`IRawTreeService.LoadMergedProbeConfigAsync` — the same blob Test-connect and
|
||||
`RawBrowseModal` dial) once per open and forwarding a live accessor; fail-soft on resolve
|
||||
failure. **Live-gated 2026-08-05, all three legs PASSED** against the same patched AbCip fixture
|
||||
this plan's second gate used. See `2026-08-05-raw-modal-picker-live-config-design.md` for the
|
||||
design and outcome (including the stale-open race the review found and closed, and Gitea **#539**
|
||||
— the AbCip discovery path dropping the authored EtherNet/IP port — which that gate uncovered).
|
||||
|
||||
## Out of scope (explicitly)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> **Program design (architecture / shared contract / build order):**
|
||||
> [`2026-07-15-driver-expansion-program-design.md`](2026-07-15-driver-expansion-program-design.md)
|
||||
>
|
||||
> Last updated: 2026-07-27.
|
||||
> Last updated: 2026-08-05.
|
||||
|
||||
## Legend
|
||||
|
||||
@@ -29,7 +29,7 @@ it reaches 📝.
|
||||
|
||||
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | S–M | none (retrofits shipped drivers) |
|
||||
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | ✅ **Done** — merged `056887d6`; live gate (Gitea #468) CLOSED 2026-08-01 via the patched AbCip fixture (`c2c3c522`) | S–M | patched libplctag `ab_server` (serves `@tags`) at `10.100.0.35:44818` |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | ✅ **Done** — merged to master `0f38f486` (#495), 11 tasks, live gate PASSED | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
|
||||
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | ✅ **Done** — merged to master `4ad54037`, 22 tasks, live gate PASSED; follow-ups #496/#497/#498 closed in `28c28667` | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
|
||||
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | ✅ **COMPLETE** — P1 Agent MVP, all 23 tasks + the 5-leg live gate PASSED (branch `feat/mtconnect-driver`, PR #506) | S–M | Dockerized `mtconnect/agent:2.7.0.12` (not `cppagent`) — CI-simulatable, live at `10.100.0.35:5000` |
|
||||
@@ -74,7 +74,7 @@ For a **new** driver the invocation shape is:
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 — Universal Discover-backed browser 🟡
|
||||
## Wave 0 — Universal Discover-backed browser ✅
|
||||
|
||||
**What it is.** One generic `DiscoveryDriverBrowser` (+ `CapturingAddressSpaceBuilder`,
|
||||
`CapturedTreeBrowseSession`, `BrowserSessionService` fallback, and the
|
||||
@@ -85,11 +85,40 @@ marginal cost.
|
||||
|
||||
- **Design:** [`2026-07-15-universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md)
|
||||
- **Implementation plan:** [`2026-07-15-universal-discovery-browser-implementation.md`](2026-07-15-universal-discovery-browser-implementation.md) · [`.tasks.json`](2026-07-15-universal-discovery-browser-implementation.md.tasks.json)
|
||||
- **Status: 🟡 code-complete + merged, live gate open.**
|
||||
- **Status: ✅ Done — merged, live gate CLOSED.**
|
||||
- Merged to master — `056887d6` (*Merge feat/universal-discovery-browser — Wave-0 universal Discover-backed browser*), 2026-07-15.
|
||||
- Implementation plan: **19 / 19 tasks completed.**
|
||||
- Lit up AbCip / TwinCAT / FOCAS pickers with zero per-driver browse code.
|
||||
- **Outstanding:** the full tree-render live `/run` gate is **fixture-blocked** — tracked as **Gitea #468**. Complete this before leaning on browse in Wave 2/3.
|
||||
- **Live gate (Gitea #468) — CLOSED 2026-08-01** (`c2c3c522`). The fixture block was removed by
|
||||
teaching the libplctag `ab_server` sim to serve the Symbol Object `@tags` walk (vendored patches in
|
||||
`tests/Drivers/…AbCip.IntegrationTests/Docker/patches/`); all three criteria then ran green on
|
||||
docker-dev — populated nine-tag tree render, Refresh re-capture, and leaf commit round-trip. See
|
||||
"Second live-gate result" in the [implementation plan](2026-07-15-universal-discovery-browser-implementation.md).
|
||||
- **The gate earned its keep — three real defects fixed, none reachable by unit tests:**
|
||||
1. `CipSymbolObjectDecoder` padded to even byte boundaries between packed entries, so any
|
||||
odd-length symbol name silently dropped every tag behind it (the test builder padded
|
||||
identically, so all tests agreed with each other and none with a controller).
|
||||
2. **Gitea #536** — browse-commit discarded the discovered data type (a browsed REAL committed as
|
||||
`DInt`), fixed by carrying the driver's own type name via `NativeDataType`; affected
|
||||
AbCip/TwinCAT/FOCAS.
|
||||
3. AbCip Test Connect misclassified the no-authored-tags probe **on real hardware too** — the
|
||||
reachable-exception matcher looked for native C status spellings libplctag.NET never emits
|
||||
(plus a sim-side 0x03/0x04 CIP status mismatch).
|
||||
- **Residual follow-up — CLOSED 2026-08-05.** The named `*DriverPage` shells were already deleted
|
||||
when v3 Batch 2 retired the routed `/clusters/{id}/drivers` flow, but the defect pattern had
|
||||
survived into the v3 hosts: `RawTagModal` and `RawManualTagEntryModal` passed a `"{}"` stub as
|
||||
`GetDriverConfigJson`, so the AbCip/TwinCAT/FOCAS pickers' "Browse controller" captured against
|
||||
no endpoint. Fixed by resolving `LoadMergedProbeConfigAsync` per open and forwarding a live
|
||||
accessor (design + outcome: `2026-08-05-raw-modal-picker-live-config-design.md`). Review of the
|
||||
fix surfaced and closed a further stale-open race (a superseded open could install another
|
||||
device's config); `RawBrowseModal` shares that pre-existing race shape and is recorded in
|
||||
`deferment.md` §0.1, not fixed here. Pinned by `RawTagModalMergedConfigTests` +
|
||||
`RawManualTagEntryModalMergedConfigTests`, and **live-gated 2026-08-05** — all three legs PASSED
|
||||
against the patched AbCip fixture (edit modal + manual entry both browse the nine-tag tree; an
|
||||
unreachable device degrades to the error chip and still saves). The gate also found **Gitea #539**:
|
||||
the AbCip *discovery* path drops the authored EtherNet/IP port, so a device on a non-default port
|
||||
browses whatever answers on 44818 — #534's defect surviving in `LibplctagTagEnumerator` +
|
||||
`LibplctagTemplateReader`.
|
||||
|
||||
---
|
||||
|
||||
@@ -249,8 +278,9 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
|
||||
## Next actions
|
||||
|
||||
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for BACnet (MTConnect
|
||||
shipped its own Task-21 live `/run` gate independently — see the plan file).
|
||||
1. **Wave 0 is done.** The live gate (Gitea #468) CLOSED 2026-08-01 (`c2c3c522`) — browse
|
||||
verification is no longer a blocker for BACnet. The residual follow-up (the picker config
|
||||
pass-through) CLOSED 2026-08-05 — see the Wave 0 section above.
|
||||
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
|
||||
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
|
||||
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Raw-modal address pickers: pass live merged config to browse — design
|
||||
|
||||
**Status:** DONE 2026-08-05 — unit suite green (AdminUI 1064/1064) and the live `/run` gate §4.2
|
||||
**PASSED** (all three legs; see §7). Closes the last dangling follow-up from the #468
|
||||
universal-browser live gate ("driver-page address pickers pass no live config"). Outcome notes in
|
||||
§6; gate evidence + the defect it uncovered (Gitea **#539**) in §7.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The #468 first-gate follow-up named `AbCipDriverPage`/`TwinCATDriverPage`/`FOCASDriverPage` as
|
||||
hosting `*AddressPickerBody` without passing `DriverType`/`GetConfigJson`. Those page shells were
|
||||
deleted when v3 Batch 2 retired the routed `/clusters/{id}/drivers` flow — but the defect pattern
|
||||
survived the migration into the two v3 hosts of the typed tag editors:
|
||||
|
||||
- `Components/Shared/Raw/RawTagModal.razor` (edit an existing raw tag) — `BuildEditorParameters()`
|
||||
passes `["GetDriverConfigJson"] = (Func<string>)(() => "{}")`.
|
||||
- `Components/Shared/Raw/RawManualTagEntryModal.razor` (manual multi-row tag entry) — same stub,
|
||||
per row.
|
||||
|
||||
The editors (`AbCipTagConfigEditor`, `TwinCATTagConfigEditor`, `FocasTagConfigEditor`) forward that
|
||||
`Func` to their `*AddressPickerBody`, whose browse gate is
|
||||
`BrowserService.CanBrowse(DriverType, GetConfigJson())`. `DiscoveryDriverBrowser.CanBrowse` creates
|
||||
a throwaway driver from the JSON — and `"{}"` parses fine into default options for AbCip / TwinCAT /
|
||||
FOCAS, all `ITagDiscovery { SupportsOnlineDiscovery: true }`. So the **"Browse controller" button
|
||||
renders enabled, and every click captures against an empty config** (no endpoint) — a guaranteed
|
||||
failure chip. The affordance is a lie in exactly the hosts where an operator would re-point a tag.
|
||||
|
||||
## 2. Decision — wire it through (not hide)
|
||||
|
||||
The #468 note offered "wire the live form config through, or hide the Browse affordance". Wire it
|
||||
through:
|
||||
|
||||
- Both hosts already hold the device context. `RawManualTagEntryModal` takes `DeviceId` as a
|
||||
parameter; `RawTagModal` receives `RawTagEditDto.DeviceId` from `LoadTagForEditAsync`.
|
||||
- The resolver already exists and is proven: `IRawTreeService.LoadMergedProbeConfigAsync(deviceId)`
|
||||
→ `(DriverType, MergedConfigJson)?` — the **same Driver+Device merge** the Test-connect probe and
|
||||
`RawBrowseModal` dial (endpoint lives in `DeviceConfig` in v3, so the merged blob is the only
|
||||
JSON a browse capture can succeed against).
|
||||
- Hiding would delete a legitimate workflow (browse while editing/creating a tag) whose whole
|
||||
supporting machinery shipped with Wave 0.
|
||||
|
||||
## 3. Design
|
||||
|
||||
Per modal, identically:
|
||||
|
||||
1. **Resolve on open.** When the modal becomes visible with a device in hand (RawTagModal: after
|
||||
`LoadTagForEditAsync` returns the dto; RawManualTagEntryModal: on open, from the `DeviceId`
|
||||
parameter), call `LoadMergedProbeConfigAsync(deviceId)` and cache the result in a nullable
|
||||
field `_mergedConfigJson`. Reset the cache when the modal closes/reopens for a different target.
|
||||
2. **Forward it.** `["GetDriverConfigJson"] = (Func<string>)(() => _mergedConfigJson ?? "{}")`.
|
||||
The `Func` is synchronous by contract (the picker body calls it inline), which is why the
|
||||
resolve must be prefetched, not lazy.
|
||||
3. **Fail soft.** A null resolve (device gone, service error) leaves the stub in place — the picker
|
||||
degrades to today's behaviour (graceful error chip on click), and the modal itself never blocks
|
||||
or errors on the resolve. Wrap the call in a swallowing try/catch carrying a constraint comment
|
||||
(as built — neither modal injects a logger, matching the sibling `RawBrowseModal` pattern; the
|
||||
picker reports its own error on use).
|
||||
|
||||
No changes to the picker bodies, the editors, `IRawTreeService`, or the browse services — the seam
|
||||
already exists end-to-end; only the two stubs change.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- The MQTT/Sparkplug and OpcUaClient pickers (bespoke browsers with their own config paths — not
|
||||
hosted through these two stubs' editors, or already receiving config).
|
||||
- Making the resolve lazy/async in the picker body (`Func<string>` contract change) — YAGNI.
|
||||
- The two documented MQTT browse-UI gaps (tracked in `docs/drivers/Mqtt.md`).
|
||||
|
||||
## 4. Testing
|
||||
|
||||
The three MQTT live gates proved this defect class (hand-maintained AdminUI wiring left behind by a
|
||||
driver-side feature) is invisible to green unit suites — so test at two layers:
|
||||
|
||||
1. **Unit (falsifiable against today's code).** The AdminUI csproj has
|
||||
`InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.AdminUI.Tests")` and precedent for testing razor
|
||||
component classes directly (`RawDriverTypeDialogParityTests`). Make each modal's editor-parameter
|
||||
construction internal-testable and assert the `GetDriverConfigJson` entry returns the cached
|
||||
merged JSON when one is loaded — this test **fails against the current stub**. Also assert the
|
||||
null-resolve fallback returns `"{}"`.
|
||||
2. **Live `/run` gate (docker-dev).** Against the patched AbCip fixture (`10.100.0.35:44818`, the
|
||||
#468 second-gate `ab_server` that serves `@tags`):
|
||||
- Open the **edit modal** for an existing AbCip raw tag → "Browse controller" → the nine-tag
|
||||
tree renders (was: error chip).
|
||||
- Open **manual entry** for the same device → same result on a row's picker.
|
||||
- Negative: a device whose endpoint is unreachable still shows the graceful error chip, and the
|
||||
modal itself stays usable.
|
||||
|
||||
## 5. Bookkeeping
|
||||
|
||||
On completion, close the loop in the two places that record the follow-up: the "Residual follow-up"
|
||||
bullet in `docs/plans/2026-07-24-driver-expansion-tracking.md` (Wave 0 section) and the
|
||||
"Follow-ups discovered during the first gate" item 2 in
|
||||
`docs/plans/2026-07-15-universal-discovery-browser-implementation.md` — both should note the pages
|
||||
were already deleted and the surviving instance (the raw modals' stubs) is fixed.
|
||||
|
||||
## 6. Outcome (2026-08-05)
|
||||
|
||||
Shipped across six commits (`e698aee4` fake, `abb9f8a1` + `4e3a6e84` RawTagModal, `a2093b44` +
|
||||
`c1c21f3f` + `2c19a50b` RawManualTagEntryModal), each TDD'd with observed-red evidence, and each
|
||||
through a spec review + code review. Things the reviews changed or decided:
|
||||
|
||||
- **The review found a real race this design missed, and it was fixed in both modals.**
|
||||
`OnParametersSetAsync` runs overlapped across rapid open/reopen (Blazor does not serialize it
|
||||
against its own outstanding task), and nothing re-checked staleness after the awaits — so a
|
||||
superseded open's late resolve could install **another device's** merged config while a different
|
||||
tag/grid was displayed, and Browse would silently dial the wrong controller. Fixed with a
|
||||
post-await staleness re-check before every write: `RawTagModal` re-checks its existing
|
||||
`_loadedKey` (after BOTH awaits — the dto write had the same shape, previously masked only by the
|
||||
EF concurrency token downstream), `RawManualTagEntryModal` a new per-open `_openGeneration`
|
||||
counter (its `_open` bool cannot tell two opens apart). Both pinned by
|
||||
`A_stale_open_never_feeds_the_picker_another_devices_config` facts, each observed red pre-guard;
|
||||
`FakeRawTreeService` gained optional Task-returning delegates so tests can express overlap.
|
||||
- ⚠️ **`RawBrowseModal` shares the same pre-existing race shape and was deliberately NOT touched**
|
||||
(out of this branch's scope). Recorded in `deferment.md` §0.1. Lower stakes there — the browse
|
||||
modal resolves the device it was opened for and shows its own error banner — but the shape is
|
||||
identical.
|
||||
- **Component-class tests must not set `[Parameter]` properties directly**: analyzer BL0005 is an
|
||||
error under `TreatWarningsAsErrors`. The two test files legitimately solve it two ways (file-local
|
||||
pragma vs a reflection `SetParameters` helper); the reflection helper is the preferred convention
|
||||
going forward. A related asymmetry: `RawTagModal.BuildEditorParameters` went `internal` for its
|
||||
test while the manual modal's stayed `private` (its `RowModel` parameter is a private nested type,
|
||||
so `internal` would not compile) — the accessibility difference is forced, not stylistic.
|
||||
- The liveness of the forwarded `Func` is load-bearing (the pickers call it at click time, after
|
||||
the async resolve) and is pinned in both files by retained-delegate facts, each falsified against
|
||||
a deliberate capture-by-value implementation before being trusted.
|
||||
|
||||
## 7. Live `/run` gate — PASSED (2026-08-05)
|
||||
|
||||
Run by the main session against the local docker-dev rig (`http://localhost:9200`, login disabled)
|
||||
with **both** central containers rebuilt from master `d88e42b8` and verified on the same image
|
||||
(`sha256:6b1ddb47…`) — the previously-running image predated the merge by three minutes, and `:9200`
|
||||
round-robins central-1/central-2, so per-container image parity is the precondition for reading
|
||||
anything into the result. Backend: the #468 patched `ab_server` (`otopcua-ab-server-controllogix`,
|
||||
`10.100.0.35:44818`), authored as `docker-dev / abcip-browse / Device1`.
|
||||
|
||||
| Leg | Result |
|
||||
|---|---|
|
||||
| **Edit modal** — `Device1/Motor_Run` → Tag config → Build → Browse controller | ✅ `BROWSER OPEN`, tree `AbCip → Device1 → Discovered` with **all nine** fixture tags (`Motor_Run`, `Line1_Count`, `Pump_1_Speed`, `TestArray`, `TestString`, `TestSINT`, `TestBOOL`, `TestREAL`, `TestDINT`). Selecting `TestREAL` landed it in the Tag-path field. |
|
||||
| **Manual entry** — device → Add tags (manual) → row Configure → Build → Browse controller | ✅ Same nine-tag tree, same `Discovered` shape. |
|
||||
| **Negative** — a device whose endpoint is unreachable | ✅ Button still renders; the click yields the graceful `ERRORTIMEOUT` chip, the grid stays usable, and the row **still saved** (a tag was created after the failed browse). |
|
||||
|
||||
The tree's second level is labelled with the **device's own name** (`Device1`, and `DeadDevice` for
|
||||
the throwaway negative-leg device) — direct evidence that the per-device merged config, not a stub,
|
||||
is what the picker captures against. Under the old `"{}"` stub no endpoint reached the capture at
|
||||
all. The probe device + its tag were deleted afterwards; the rig is back to its pre-gate state.
|
||||
|
||||
### The gate found a real defect — Gitea #539 (AbCip discovery drops the authored port)
|
||||
|
||||
The negative leg was first run against `ab://10.100.0.35:44999/1,0` (nothing listening on 44999) and
|
||||
**browsed successfully**, returning the fixture's nine tags — while **Test Connect on the same merged
|
||||
config correctly failed** `CONNECT FAILED: CONNECTIONREFUSED`. That divergence is not this change: the
|
||||
AbCip discovery path passes libplctag the bare host, so libplctag substitutes its own 44818 default —
|
||||
Gitea **#534**'s defect surviving in `LibplctagTagEnumerator.cs:33` and `LibplctagTemplateReader.cs:24`
|
||||
(the data path was fixed and uses `LibplctagGateway`). Consequence: browsing a device on a non-default
|
||||
port shows **whatever answers on 44818**, which on a plant network is another PLC.
|
||||
|
||||
Re-pointing the device at `ab://127.0.0.1/1,0` produced the expected failure, which is the run
|
||||
recorded in the table above. Two things worth carrying forward:
|
||||
|
||||
- ⚠️ **A "bad endpoint" chosen by changing only the port is not a valid negative control for
|
||||
anything libplctag-backed** — the port may never reach the wire. Change the **host**.
|
||||
- The fixture sitting on the default 44818 is exactly why #534's discovery half survived its own
|
||||
fix: on 44818 the bug is invisible by construction, so no existing test could see it.
|
||||
@@ -0,0 +1,555 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-05-raw-modal-picker-live-config.md",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: FakeRawTreeService test helper",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: RawTagModal \u2014 resolve and forward merged config",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: RawManualTagEntryModal \u2014 resolve and forward merged config",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Full-suite verification",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Documentation bookkeeping",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: Live /run gate (main session)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-08-05T18:00:00Z"
|
||||
}
|
||||
+33
-3
@@ -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,19 +160,48 @@
|
||||
private bool _summaryOk;
|
||||
private bool _open; // guards re-seeding rows on unrelated re-renders
|
||||
|
||||
protected override void OnParametersSet()
|
||||
// Identifies WHICH open a post-await write belongs to; _open alone cannot tell two opens apart.
|
||||
private int _openGeneration;
|
||||
|
||||
// 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
|
||||
_open = true;
|
||||
var generation = ++_openGeneration;
|
||||
_rows.Clear();
|
||||
_rows.Add(new RowModel());
|
||||
_summary = null;
|
||||
_summaryOk = false;
|
||||
|
||||
_mergedConfigJson = null;
|
||||
if (!string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
|
||||
// A close-reopen onto another device can complete while this resolve is in flight, so a
|
||||
// superseded open must never install its device's config under the displayed grid.
|
||||
if (generation != _openGeneration || !Visible) { return; }
|
||||
|
||||
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 +209,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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -163,6 +170,11 @@
|
||||
}
|
||||
|
||||
var dto = await Svc.LoadTagForEditAsync(TagId);
|
||||
// Two opens can overlap on one modal instance (the tree's Edit command is synchronous and does
|
||||
// not disable while this resolves), so every post-await write is discarded unless this open is
|
||||
// still the displayed one. Last-completion-wins would point the picker at another device.
|
||||
if (_loadedKey != key || !Visible) { return; }
|
||||
|
||||
// RawTagEditDto is a record struct → a missing tag comes back as default (all-zero), not null.
|
||||
if (dto is not { } d || string.IsNullOrEmpty(d.TagId))
|
||||
{
|
||||
@@ -180,14 +192,33 @@
|
||||
TagConfig = string.IsNullOrWhiteSpace(d.TagConfig) ? "{}" : d.TagConfig,
|
||||
};
|
||||
_rowVersion = d.RowVersion;
|
||||
|
||||
_mergedConfigJson = null;
|
||||
try
|
||||
{
|
||||
var merged = await Svc.LoadMergedProbeConfigAsync(d.DeviceId);
|
||||
if (_loadedKey != key || !Visible) { return; } // superseded open → never install its device
|
||||
|
||||
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,139 @@
|
||||
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 (AdminUI has no bUnit, so the
|
||||
/// raw modals are exercised as plain classes with this injected). Only the raw modals' open-path
|
||||
/// members — <see cref="LoadTagForEditAsync"/> and <see cref="LoadMergedProbeConfigAsync"/> — are
|
||||
/// configurable, via the two settable delegates; every other member throws, so an unexpected call
|
||||
/// fails the test instead of passing vacuously. The two delegates themselves default to throwing for
|
||||
/// the same reason: a test must opt in to the calls it expects.
|
||||
/// </summary>
|
||||
internal sealed class FakeRawTreeService : IRawTreeService
|
||||
{
|
||||
/// <summary>The <see cref="LoadTagForEditAsync"/> stand-in; throws until a test sets it.</summary>
|
||||
public Func<string, RawTagEditDto?> OnLoadTagForEdit { get; set; } =
|
||||
_ => throw new NotImplementedException();
|
||||
|
||||
/// <summary>The <see cref="LoadMergedProbeConfigAsync"/> stand-in; throws until a test sets it.</summary>
|
||||
public Func<string, (string DriverType, string MergedConfigJson)?> OnLoadMergedProbeConfig { get; set; } =
|
||||
_ => throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Optional asynchronous override of <see cref="OnLoadTagForEdit"/>. The sync delegates above
|
||||
/// complete inline, which cannot express two opens overlapping on one modal instance — set this to
|
||||
/// gate a load on a <see cref="TaskCompletionSource"/> and interleave the second open.
|
||||
/// </summary>
|
||||
public Func<string, Task<RawTagEditDto?>>? OnLoadTagForEditAsync { get; set; }
|
||||
|
||||
/// <summary>Optional asynchronous override of <see cref="OnLoadMergedProbeConfig"/>; see <see cref="OnLoadTagForEditAsync"/>.</summary>
|
||||
public Func<string, Task<(string DriverType, string MergedConfigJson)?>>? OnLoadMergedProbeConfigAsync { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default)
|
||||
=> OnLoadTagForEditAsync is { } async ? async(tagId) : Task.FromResult(OnLoadTagForEdit(tagId));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(
|
||||
string deviceId, CancellationToken ct = default)
|
||||
=> OnLoadMergedProbeConfigAsync is { } async
|
||||
? async(deviceId)
|
||||
: Task.FromResult(OnLoadMergedProbeConfig(deviceId));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<RawNode>> LoadRootsAsync(CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<RawNode>> LoadChildrenAsync(RawNode parent, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> CreateFolderAsync(string clusterId, string? parentFolderId, string name, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawRenameResult> RenameFolderAsync(string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> DeleteFolderAsync(string rawFolderId, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> MoveDriverToFolderAsync(string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> CreateDriverAsync(string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawRenameResult> RenameDriverAsync(string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> SetDriverEnabledAsync(string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> DeleteDriverAsync(string driverInstanceId, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> CreateDeviceAsync(string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawRenameResult> RenameDeviceAsync(string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> DeleteDeviceAsync(string deviceId, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> CreateTagGroupAsync(string deviceId, string? parentGroupId, string name, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawRenameResult> RenameTagGroupAsync(string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> DeleteTagGroupAsync(string tagGroupId, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> CreateTagAsync(string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> UpdateTagAsync(string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawTagImportResult> ImportTagsAsync(string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawDriverEditDto?> LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> UpdateDriverAsync(string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RawDeviceEditDto?> LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<UnsMutationResult> UpdateDeviceAsync(string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
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("{}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_accessor_is_live_not_a_captured_snapshot()
|
||||
{
|
||||
const string merged = """{"endpoint":"10.0.0.1:44818","slot":0}""";
|
||||
|
||||
(string DriverType, string MergedConfigJson)? answer = null;
|
||||
var svc = new FakeRawTreeService { OnLoadMergedProbeConfig = _ => answer };
|
||||
|
||||
var modal = new RawManualTagEntryModal();
|
||||
SetParameters(modal, visible: true, deviceId: "dev-1", driverType: "AbCip");
|
||||
InjectService(modal, svc);
|
||||
|
||||
await OpenAsync(modal);
|
||||
|
||||
// The delegate an already-rendered editor is holding. It must read the field on every call, not a
|
||||
// value captured when the parameters were built — the resolve is awaited AFTER the rows are
|
||||
// seeded, so a snapshot taken at build time is permanently "{}".
|
||||
var accessor = GetAccessor(modal);
|
||||
accessor().ShouldBe("{}");
|
||||
|
||||
SetParameters(modal, visible: false, deviceId: "dev-1", driverType: "AbCip");
|
||||
await OpenAsync(modal);
|
||||
|
||||
answer = ("AbCip", merged);
|
||||
SetParameters(modal, visible: true, deviceId: "dev-1", driverType: "AbCip");
|
||||
await OpenAsync(modal);
|
||||
|
||||
accessor().ShouldBe(merged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_open_never_feeds_the_picker_another_devices_config()
|
||||
{
|
||||
const string deviceA = """{"endpoint":"10.0.0.1:44818","slot":0}""";
|
||||
const string deviceB = """{"endpoint":"10.0.0.2:44818","slot":1}""";
|
||||
|
||||
var deviceAResolve = new TaskCompletionSource();
|
||||
var svc = new FakeRawTreeService
|
||||
{
|
||||
OnLoadMergedProbeConfigAsync = async deviceId =>
|
||||
{
|
||||
if (deviceId == "dev-A") { await deviceAResolve.Task; return ("AbCip", deviceA); }
|
||||
return ("AbCip", deviceB);
|
||||
},
|
||||
};
|
||||
|
||||
var modal = new RawManualTagEntryModal();
|
||||
InjectService(modal, svc);
|
||||
|
||||
// Quick close-reopen onto a different device while the first resolve is still in flight — the
|
||||
// grid shows dev-B, so dev-A's late continuation must not install its config underneath it.
|
||||
SetParameters(modal, visible: true, deviceId: "dev-A", driverType: "AbCip");
|
||||
var staleOpen = OpenAsync(modal);
|
||||
|
||||
SetParameters(modal, visible: false, deviceId: "dev-A", driverType: "AbCip");
|
||||
await OpenAsync(modal);
|
||||
|
||||
SetParameters(modal, visible: true, deviceId: "dev-B", driverType: "AbCip");
|
||||
await OpenAsync(modal);
|
||||
|
||||
deviceAResolve.SetResult();
|
||||
await staleOpen;
|
||||
|
||||
GetDriverConfigJson(modal).ShouldBe(deviceB);
|
||||
}
|
||||
|
||||
/// <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) => GetAccessor(modal)();
|
||||
|
||||
/// <summary>The <c>GetDriverConfigJson</c> Func itself, so a test can retain and re-invoke it.</summary>
|
||||
private static Func<string> GetAccessor(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"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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}""";
|
||||
|
||||
/// <summary>A second device's config — the blob a superseded open must never install.</summary>
|
||||
private const string StaleConfig = """{"endpoint":"10.0.0.9:44818","slot":3}""";
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_open_never_feeds_the_picker_another_devices_config()
|
||||
{
|
||||
// RawTree's OnEditTag is synchronous and does not disable while the modal resolves, so a rapid
|
||||
// double-click across two tag rows overlaps two opens on ONE modal instance. Every write after an
|
||||
// await must therefore be discarded if the open it belongs to is no longer the displayed one:
|
||||
// last-completion-wins would silently point a live browse at the wrong controller.
|
||||
var gate = new TaskCompletionSource<(string DriverType, string MergedConfigJson)?>();
|
||||
var svc = new FakeRawTreeService
|
||||
{
|
||||
OnLoadTagForEdit = tagId => TagOn(tagId, tagId == "tag-1" ? "dev-A" : "dev-B"),
|
||||
OnLoadMergedProbeConfigAsync = deviceId => deviceId == "dev-A"
|
||||
? gate.Task
|
||||
: Task.FromResult<(string DriverType, string MergedConfigJson)?>(("AbCip", MergedConfig)),
|
||||
};
|
||||
|
||||
var modal = NewModal(svc, "tag-1");
|
||||
var firstOpen = InvokeParametersSetAsync(modal);
|
||||
|
||||
// The second open supersedes the first while the first is still parked on dev-A's resolve.
|
||||
modal.TagId = "tag-2";
|
||||
await InvokeParametersSetAsync(modal);
|
||||
|
||||
gate.SetResult(("AbCip", StaleConfig));
|
||||
await firstOpen;
|
||||
|
||||
DriverConfigJson(modal).ShouldBe(
|
||||
MergedConfig,
|
||||
"the superseded open's dev-A config overwrote the displayed tag's dev-B config");
|
||||
}
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user