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
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper
return new MTConnectTagConfigModel { FullName = address }.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address);
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
// Sql editor does not read: it would open with empty fields and blank them on save — the exact
// failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable
// (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue.
if (Is(driverType, DriverTypeNames.Sql))
return BuildSqlTagConfig(address, addressFields);
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
// "address" key so nothing is lost. Browsable drivers are all handled above.
// "address" key so nothing is lost. Every BROWSABLE driver is handled above — guarded by
// RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting
// this comment.
return WriteSingleKey("address", address);
}
@@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper
private static bool Is(string driverType, string name)
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Builds a <c>SqlTagConfigModel</c> blob from a browsed COLUMN leaf. Maps to
/// <c>SqlTagModel.WideRow</c> — the browse tree is schema → table → column, which is precisely the
/// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived
/// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the
/// typed editor.
/// <para>Falls back to the generic key when the fields are absent — an older browser build, or a
/// hand-made selection — rather than emitting a half-built Sql blob.</para>
/// </summary>
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
{
if (addressFields is null
|| !addressFields.TryGetValue("table", out var table)
|| string.IsNullOrWhiteSpace(table))
{
return WriteSingleKey("address", address);
}
addressFields.TryGetValue("schema", out var schema);
addressFields.TryGetValue("columnName", out var columnName);
// Qualify the table with its schema when the schema is not the default, so a tag authored against
// "sales.Readings" cannot silently resolve to "dbo.Readings".
var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase)
? $"{schema}.{table}"
: table;
return new SqlTagConfigModel
{
Model = SqlTagModel.WideRow,
Table = qualified,
ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName,
}.ToJson();
}
private static string WriteSingleKey(string key, string value)
{
var o = new JsonObject { [key] = value };