A parity guard already existed for DriverTypeNames <-> the /raw driver picker,
but not for the two dispatch maps downstream, so a driver could be registered,
offered in the picker, and then have no config form. That is exactly what
happened to Calculation (G-1) and to Sql/MTConnect/Calculation on the device
modal (G-2) — both survived review because nothing could see them.
The maps had to become data first. A Razor @switch compiles into
BuildRenderTree's IL, so no test can enumerate its cases; the picker guard
works only because RawDriverTypeDialog keeps its data in a field the markup
enumerates. Both modals now render from DriverConfigFormMap / DeviceFormMap
through <DynamicComponent>, and being public those maps need none of the
picker test's BindingFlags.NonPublic fragility.
- G-1 CalculationDriverForm.razor — RunTimeout was unauthorable via the UI.
- G-2 Sql/MTConnect/Calculation declared single-connection rather than given
hollow device forms; each holds one connection at the driver level.
- G-3 DriverConfigModal's hardcoded "Galaxy or Mqtt" -> IsSingleConnection, so
Sql/MTConnect authors are no longer sent to a device form with no endpoint.
- G-4 DriverFormMapParityTests (5, both directions) + DriverDispatchMapParityTests.
- G-5 CsvColumnMap entries for Sql, Mqtt, MTConnect.
- G-6 RawBrowseCommitMapper Sql branch — and the browser end, which the audit
missed: SqlBrowseSession emitted NO AddressFields, so a browsed leaf carried
only a column name, and a column alone cannot address a Sql tag. It now
travels schema/table/column and the mapper builds a WideRow config from them.
A branch alone would have produced a half-built blob.
Two findings while writing the guards. The G-6 test showed Modbus and
Calculation also hit the generic address fallback — correctly, as neither is
browsable — so it asserts the fall-through set EQUALS a documented
non-browsable list in both directions; a one-way check would let a newly
browsable driver be quietly added to the exclusion list. And
TagConfigDriverTypeNameGuardTests was hollow: it enumerated a hand-written
TheoryData that had already drifted (omitting Galaxy, Sql and Mqtt), guarding
renames but not gaps. Now enumerated from the map with a coverage test facing
the other way.
Live-verified on docker-dev, since this repo has no bUnit and no unit test
reaches Blazor parameter binding: opened Calculation's config (previously "No
typed config form"), typed 3500, saved, reopened — the value persisted, so the
DynamicComponent two-way binding round-trips. Sql's form renders fully and now
reads "This driver holds a single connection, authored above".
AdminUI.Tests 930 passed.
Registers the MTConnect typed tag editor in TagConfigEditorMap /
TagConfigValidator (keyed off DriverTypeNames.MTConnect, never a literal) and
adds the razor shell over Task 17's MTConnectTagConfigModel.
The shell stays trivial: the /probe-sourced mtCategory/mtType/mtSubType/units
row is rendered disabled + readonly with NO binding of any kind, and no
inferred-type hint is shown — MTConnectDataTypeInference.Infer() also keys on
the DataItem `representation`, which the tag-config model does not carry, so
calling it here would agree with the browse picker for ordinary items and
silently disagree for exactly the DATA_SET/TABLE/TIME_SERIES ones.
Also fixes the picker -> editor round trip, which was broken in both
directions:
- RawBrowseCommitMapper.BuildTagConfig had no MTConnect branch, so a committed
tag landed as the generic {"address": "<dataItemId>"} while the model read
only `fullName`. The data plane kept working (the driver accepts all three id
spellings), so the only symptom was an empty DataItem-id field in the editor
— and saving from that state stamped fullName:"" over the tag.
- MTConnectTagConfigModel.FromJson now accepts dataItemId/address as fallbacks
(fixing tags committed before this change) and ToJson normalises onto
`fullName`, dropping the aliases so the two spellings cannot drift.
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.
Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.
The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.
Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.
Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Browse device… on a /raw Device/TagGroup opens the discovery-browser modal
against the merged Driver+Device config and commits selected browse leaves as
raw Tag rows under the target, via IRawTreeService.ImportTagsAsync.
- RawBrowseModal.razor: two-tier browse gate (bespoke IDriverBrowser →
universal DiscoveryDriverBrowser via SupportsOnlineDiscovery → disabled),
merged config from LoadMergedProbeConfigAsync, multi-select over
DriverBrowseTree, opt-in 'create matching tag-groups' folder mirror,
commit via ImportTagsAsync.
- RawBrowseCommitMapper: pure leaf→RawTagImportRow mapper — DriverDataType→
OPC UA type, per-driver address field (nodeId/tagPath/symbolPath/address/
attributeRef), target-group + mirror path combine. Unit-tested (39 tests).
- DriverBrowseTree: additive multi-select mode (checkboxes + ancestor-path
callback), default off preserves single-select pickers.
- RawTree: OnBrowseDevice wired to the modal (Device + TagGroup menus).
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox