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
@@ -39,38 +39,23 @@
</div>
</div>
@switch (_driverType)
@* Rendered from DeviceFormMap — see DriverConfigFormMap for why this is a map and
not a @switch. Drivers in DeviceFormMap.SingleConnectionDriverTypes legitimately
have no per-device form; the parity test consults that set. *@
@if (DeviceFormMap.Resolve(_driverType) is { } deviceFormType)
{
case DriverTypeNames.Modbus:
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.S7:
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbCip:
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.FOCAS:
<FocasDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Mqtt:
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
<DynamicComponent Type="deviceFormType" Parameters="_deviceFormParameters" />
}
else if (DeviceFormMap.IsSingleConnection(_driverType))
{
<div class="alert alert-info">
This driver holds a single connection, authored on the <strong>driver</strong>.
There is nothing to configure per device.
</div>
}
else
{
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
}
<div class="mt-3">
@@ -120,6 +105,14 @@
private string _name = "";
private bool _enabled = true;
private string _deviceConfigJson = "{}";
/// <summary>Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render —
/// see DriverConfigModal for why this must not be cached.</summary>
private Dictionary<string, object> _deviceFormParameters => new()
{
["DeviceConfigJson"] = _deviceConfigJson,
["DeviceConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
};
private byte[] _rowVersion = [];
private string _parentDriverConfig = "{}";