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 = "{}";
@@ -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 = [];
@@ -0,0 +1,105 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
/// <summary>
/// <c>DriverType → typed driver-config form component</c>, rendered by <c>DriverConfigModal</c> through
/// <c>&lt;DynamicComponent&gt;</c>.
/// <para><b>Why this is a map and not the <c>@switch</c> it replaced.</b> A Razor <c>@switch</c> compiles
/// into <c>BuildRenderTree</c>'s IL, so no test can enumerate its cases — which is exactly how the modal
/// came to be missing a <c>Calculation</c> arm while the sibling driver-picker guard stayed green
/// (<c>deferment.md</c> G-1/G-4). The picker test only works because <c>RawDriverTypeDialog</c> keeps its
/// data in a field the markup enumerates; this type gives the two config modals the same property, and
/// being <c>public</c> it needs none of that test's <c>BindingFlags.NonPublic</c> fragility.</para>
/// <para>Modelled on <c>TagConfigEditorMap</c>. Guarded by <c>DriverFormMapParityTests</c>.</para>
/// </summary>
public static class DriverConfigFormMap
{
private static readonly IReadOnlyDictionary<string, Type> Map =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Modbus] = typeof(ModbusDriverForm),
[DriverTypeNames.S7] = typeof(S7DriverForm),
[DriverTypeNames.AbCip] = typeof(AbCipDriverForm),
[DriverTypeNames.AbLegacy] = typeof(AbLegacyDriverForm),
[DriverTypeNames.TwinCAT] = typeof(TwinCATDriverForm),
[DriverTypeNames.FOCAS] = typeof(FocasDriverForm),
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDriverForm),
[DriverTypeNames.Galaxy] = typeof(GalaxyDriverForm),
[DriverTypeNames.Sql] = typeof(SqlDriverForm),
[DriverTypeNames.Mqtt] = typeof(MqttDriverForm),
[DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm),
[DriverTypeNames.Calculation] = typeof(CalculationDriverForm),
};
/// <summary>The driver types that have a typed config form.</summary>
public static IReadOnlyCollection<string> MappedDriverTypes => (IReadOnlyCollection<string>)Map.Keys;
/// <summary>Resolves the form component for a driver type, or null when none is mapped (the modal
/// then renders its "no typed config form" warning rather than crashing).</summary>
/// <param name="driverType">The driver type name.</param>
/// <returns>The form component type, or <see langword="null"/>.</returns>
public static Type? Resolve(string? driverType) =>
driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
}
/// <summary>
/// <c>DriverType → typed device-config form component</c>, rendered by <c>DeviceModal</c>.
/// <para><b>Not every driver has one, by design.</b> The types in
/// <see cref="SingleConnectionDriverTypes"/> hold ONE connection per driver instance and author it on the
/// driver form, so a per-device endpoint editor would be meaningless. The parity test consults that set
/// rather than demanding total coverage — which keeps it a real guard instead of one that has to be
/// suppressed.</para>
/// </summary>
public static class DeviceFormMap
{
private static readonly IReadOnlyDictionary<string, Type> Map =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Modbus] = typeof(ModbusDeviceForm),
[DriverTypeNames.S7] = typeof(S7DeviceForm),
[DriverTypeNames.AbCip] = typeof(AbCipDeviceForm),
[DriverTypeNames.AbLegacy] = typeof(AbLegacyDeviceForm),
[DriverTypeNames.TwinCAT] = typeof(TwinCATDeviceForm),
[DriverTypeNames.FOCAS] = typeof(FocasDeviceForm),
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm),
[DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm),
[DriverTypeNames.Mqtt] = typeof(MqttDeviceForm),
};
/// <summary>
/// Driver types that hold a single connection at the DRIVER level, so they legitimately have no
/// per-device form. Galaxy and Mqtt author a connection on the driver form and keep an informational
/// device form; Sql (one connection string), MTConnect (one Agent URI) and Calculation (no
/// connection at all) have no device form to render.
/// <para>Read by <c>DriverConfigModal</c> so the operator is told where the endpoint actually lives —
/// it previously hardcoded Galaxy-or-Mqtt and told Sql/MTConnect authors to go and find an endpoint
/// field on the device that does not exist (<c>deferment.md</c> G-3).</para>
/// </summary>
public static readonly IReadOnlySet<string> SingleConnectionDriverTypes =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
DriverTypeNames.Galaxy,
DriverTypeNames.Mqtt,
DriverTypeNames.Sql,
DriverTypeNames.MTConnect,
DriverTypeNames.Calculation,
};
/// <summary>The driver types that have a typed device form.</summary>
public static IReadOnlyCollection<string> MappedDriverTypes => (IReadOnlyCollection<string>)Map.Keys;
/// <summary>Resolves the device form component for a driver type, or null when none is mapped.</summary>
/// <param name="driverType">The driver type name.</param>
/// <returns>The form component type, or <see langword="null"/>.</returns>
public static Type? Resolve(string? driverType) =>
driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
/// <summary>True when this driver authors its connection on the DRIVER form rather than per-device.</summary>
/// <param name="driverType">The driver type name.</param>
/// <returns><see langword="true"/> when the connection is driver-level.</returns>
public static bool IsSingleConnection(string? driverType) =>
driverType is not null && SingleConnectionDriverTypes.Contains(driverType);
}
@@ -0,0 +1,103 @@
@* Embeddable Calculation pseudo-driver config form body. The Calculation driver has NO connection of any
kind — it computes signal-level tags from other tags' RawPaths — so its only driver-level knob is the
per-run script timeout. rawTags is composer-owned and never authored here.
Closes deferment.md G-1: Calculation was offered in the /raw driver picker and registered by the factory,
but DriverConfigModal had no arm for it, so it fell through to the "No typed config form" warning and
RunTimeout was unauthorable through the UI. Hosted by DriverConfigModal via DriverConfigFormMap. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Evaluation</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Run timeout (ms)</label>
<InputNumber @bind-Value="_form.RunTimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">
Per-evaluation deadline for a calculated tag's script. Blank = the driver default (2000 ms).
A script exceeding it is abandoned and the tag publishes Bad.
</div>
</div>
</div>
<p class="form-text mt-3 mb-0">
This driver has no connection to author — it reads other tags by RawPath. Its calculated tags and
their scripts are authored on the tags themselves under <strong>/raw</strong>.
</p>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON.</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
// camelCase + omit-nulls, matching the driver's case-insensitive deserialize. Omitting runTimeoutMs
// means "use the driver default" rather than pinning the current default into the blob.
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed, so an in-progress edit is not clobbered.
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
_form = new FormModel { RunTimeoutMs = TryDeserialize(DriverConfigJson)?.RunTimeoutMs };
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current config to camelCase JSON. rawTags is never emitted — the composer
/// adds it at deploy time.</summary>
public string GetConfigJson() => JsonSerializer.Serialize(new Dto { RunTimeoutMs = _form.RunTimeoutMs }, _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static Dto? TryDeserialize(string json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonSerializer.Deserialize<Dto>(json, _jsonOpts); }
catch (JsonException) { return null; } // a hand-edited blob must not crash the modal
}
/// <summary>Mirrors the driver's own config DTO. rawTags is deliberately absent — it is composer-owned,
/// and round-tripping it through this form would let the editor drop tags on save.</summary>
private sealed class Dto
{
public int? RunTimeoutMs { get; init; }
}
private sealed class FormModel
{
public int? RunTimeoutMs { get; set; }
}
}