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:
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Closes <c>deferment.md</c> G-4. A guard already existed for <c>DriverTypeNames</c> ↔ the <c>/raw</c>
|
||||
/// driver PICKER, but not for the two dispatch maps downstream of it, so a driver could be registered,
|
||||
/// offered in the picker, and then have no config form — which is exactly what happened to
|
||||
/// <c>Calculation</c> (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2). Both survived
|
||||
/// review because nothing could see them.
|
||||
/// <para>These are ordinary map lookups rather than reflection over a private field: the two modals were
|
||||
/// converted from a Razor <c>@switch</c> — unreachable from a test, since it compiles into
|
||||
/// <c>BuildRenderTree</c>'s IL — to <c>DriverConfigFormMap</c> / <c>DeviceFormMap</c>. There is no bUnit
|
||||
/// in this project, so a map is the only thing a test can hold on to.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverFormMapParityTests
|
||||
{
|
||||
/// <summary>Every declared driver type must have a typed config form. There is no exemption: a driver
|
||||
/// with no configurable surface still needs a form to say so, or the operator meets a bare warning.</summary>
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_has_a_typed_config_form()
|
||||
{
|
||||
var missing = DriverTypeNames.All
|
||||
.Where(t => DriverConfigFormMap.Resolve(t) is null)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"these driver types are registered and offered in the /raw picker but DriverConfigModal has no "
|
||||
+ $"form for them, so their config is unauthorable through the UI: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
/// <summary>The reverse direction — a mapped type that is no longer a declared driver type is a stale
|
||||
/// entry, and would keep a deleted driver's form reachable.</summary>
|
||||
[Fact]
|
||||
public void Every_config_form_maps_to_a_declared_driver_type()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var stale = DriverConfigFormMap.MappedDriverTypes
|
||||
.Where(t => !declared.Contains(t))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
stale.ShouldBeEmpty($"DriverConfigFormMap has entries for undeclared driver types: {string.Join(", ", stale)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every declared driver type must EITHER have a typed device form OR be declared single-connection.
|
||||
/// <para>The either/or matters: demanding a device form from every driver would be wrong (Sql has one
|
||||
/// connection string, MTConnect one Agent URI, Calculation no connection at all), and a test that has
|
||||
/// to be suppressed for five drivers stops being read. Forcing the choice to be explicit is what makes
|
||||
/// this a guard — a new driver cannot silently fall through.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_has_a_device_form_or_is_declared_single_connection()
|
||||
{
|
||||
var unaccounted = DriverTypeNames.All
|
||||
.Where(t => DeviceFormMap.Resolve(t) is null && !DeviceFormMap.IsSingleConnection(t))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
unaccounted.ShouldBeEmpty(
|
||||
$"these driver types have neither a typed device form nor a place in "
|
||||
+ $"DeviceFormMap.SingleConnectionDriverTypes, so DeviceModal falls through to its "
|
||||
+ $"\"no typed device form\" warning: {string.Join(", ", unaccounted)}");
|
||||
}
|
||||
|
||||
/// <summary>The reverse direction for the device map.</summary>
|
||||
[Fact]
|
||||
public void Every_device_form_maps_to_a_declared_driver_type()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var stale = DeviceFormMap.MappedDriverTypes
|
||||
.Concat(DeviceFormMap.SingleConnectionDriverTypes)
|
||||
.Where(t => !declared.Contains(t))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
stale.ShouldBeEmpty($"DeviceFormMap references undeclared driver types: {string.Join(", ", stale)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver that authors its connection on the DRIVER form must have a driver form to author it on.
|
||||
/// Without this, declaring a type single-connection would be a way to opt out of both maps at once
|
||||
/// and leave the connection unauthorable anywhere.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_single_connection_driver_has_a_driver_config_form()
|
||||
{
|
||||
var missing = DeviceFormMap.SingleConnectionDriverTypes
|
||||
.Where(t => DriverConfigFormMap.Resolve(t) is null)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"these types are declared single-connection but have no driver config form, so their connection "
|
||||
+ $"cannot be authored anywhere: {string.Join(", ", missing)}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user