Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs
T
Joseph Doherty 6a01358b9a 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.
2026-07-27 19:52:02 -04:00

106 lines
6.1 KiB
C#

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);
}