fix(adminui): guard the driver dispatch maps and close G-1..G-6 (§8.4)

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.
This commit is contained in:
Joseph Doherty
2026-07-27 19:52:02 -04:00
parent d32d89c340
commit 6a01358b9a
14 changed files with 658 additions and 102 deletions
@@ -31,50 +31,25 @@
</div>
</div>
@switch (_driverType)
@* Rendered from DriverConfigFormMap rather than a @switch: a Razor switch compiles
into BuildRenderTree's IL, so no test can enumerate its cases — which is how this
modal came to be missing a Calculation arm while the driver-picker guard stayed
green (deferment.md G-1/G-4). The map is guarded by DriverFormMapParityTests. *@
@if (DriverConfigFormMap.Resolve(_driverType) is { } formType)
{
case DriverTypeNames.Modbus:
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.S7:
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbCip:
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.FOCAS:
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Sql:
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.MTConnect:
<MTConnectDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
<DynamicComponent Type="formType" Parameters="_formParameters" />
}
else
{
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
}
@* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and
their device forms are informational. Every other driver splits the endpoint onto
the device (the v3 endpoint→DeviceConfig split). *@
@if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt)
@* Some drivers hold ONE connection per driver instance and author it here; the rest
split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is
DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and
so told Sql and MTConnect authors to go find an endpoint field on a device form that
does not exist (deferment.md G-3). *@
@if (DeviceFormMap.IsSingleConnection(_driverType))
{
<p class="form-text mt-3 mb-0">
This driver holds a single connection, authored above. Test-connect lives on its
@@ -133,6 +108,17 @@
private string _driverType = "";
private string _name = "";
private string _driverConfigJson = "{}";
/// <summary>Parameters handed to the DynamicComponent-rendered driver form. Rebuilt on every render so
/// the child receives the CURRENT json — a cached dictionary would pin the value captured at first
/// render and silently strand every subsequent edit.</summary>
private Dictionary<string, object> _formParameters => new()
{
["DriverConfigJson"] = _driverConfigJson,
["DriverConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _driverConfigJson = v),
["ResilienceConfig"] = _resilienceConfig!,
["ResilienceConfigChanged"] = EventCallback.Factory.Create<string?>(this, v => _resilienceConfig = v),
};
private string? _resilienceConfig;
private byte[] _rowVersion = [];