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:
@@ -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 = "{}";
|
||||
|
||||
|
||||
+27
-41
@@ -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><DynamicComponent></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);
|
||||
}
|
||||
+103
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user