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
+46 -3
View File
@@ -355,8 +355,9 @@ appear in §7.1 as well.
so it was deleted rather than fixed. See §9. so it was deleted rather than fixed. See §9.
3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~ 3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
**Done.** See §9. #489 is still open and should now be re-scoped. **Done.** See §9. #489 is still open and should now be re-scoped.
4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would 4. ~~**G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
have caught G-1 and G-2 for free, and this class has now recurred twice. have caught G-1 and G-2 for free, and this class has now recurred twice.~~ ✅ **Done**, together with
G-1, G-2, G-3, G-5 and G-6. See §9.
5. **Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan 5. **Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
(§6.2) is visible as the real backlog it is. (§6.2) is visible as the real backlog it is.
6. **§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C 6. **§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C
@@ -374,7 +375,7 @@ the work, so this register never disagrees with the tree.
| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | | 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` |
| 2. #518 + #507 | ✅ **Done**`IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` | | 2. #518 + #507 | ✅ **Done**`IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` |
| 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` | | 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` |
| 4. G-4 dispatch-map parity | ⏳ Not started | — | | 4. G-4 dispatch-map parity | **Done** — G-1…G-6 all closed, live-verified on docker-dev | `abacf4cf` |
| 5. Bookkeeping sweep | ⏳ Not started | — | | 5. Bookkeeping sweep | ⏳ Not started | — |
| 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — | | 6. Tier truth | ⏳ Not started — decided *document the truth, defer the code decision* | — |
@@ -465,6 +466,48 @@ Two traps worth keeping:
`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on `DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
inference would be wrong. Split to Gitea **#521** with both options costed. inference would be wrong. Split to Gitea **#521** with both options costed.
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
`BuildRenderTree`'s IL, so nothing can enumerate its cases — that is the whole reason G-1 and G-2
survived review while the sibling picker guard stayed green (the picker test works only because
`RawDriverTypeDialog` keeps its data in a *field* the markup enumerates). Both modals now render from
`DriverConfigFormMap` / `DeviceFormMap` via `<DynamicComponent>`, and being `public` those maps need
none of the picker test's `BindingFlags.NonPublic` fragility.
| Gap | Resolution |
|---|---|
| G-1 | `CalculationDriverForm.razor` added — `RunTimeout` was unauthorable through the UI. |
| G-2 | Sql / MTConnect / Calculation are declared **single-connection** rather than given hollow device forms — each holds one connection at the driver level, so a per-device endpoint editor would be meaningless. |
| G-3 | `DriverConfigModal`'s hardcoded `Galaxy or Mqtt` replaced by `DeviceFormMap.IsSingleConnection`. |
| G-4 | `DriverFormMapParityTests` (5 cases, both directions) + `DriverDispatchMapParityTests` (3). |
| G-5 | `CsvColumnMap` entries for Sql, Mqtt, MTConnect. |
| G-6 | `RawBrowseCommitMapper` Sql branch — **and the browser end too**, which the register missed. |
**G-6 was bigger than filed.** The register said the mapper lacked a Sql branch. It also turned out that
`SqlBrowseSession` emitted **no `AddressFields` at all**, so a browsed leaf carried only a column name —
and a column name alone cannot address a Sql tag, which needs its table. The browser now travels
schema/table/column and the mapper builds a `WideRow` `SqlTagConfigModel` from them. A branch alone
would have produced a half-built blob.
**Two extra findings while writing the guards:**
- The G-6 test found **Modbus and Calculation** also fall through to the generic `{"address": …}` key.
Both are correct — neither is browsable — so the test 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 instead of getting a branch.
- `TagConfigDriverTypeNameGuardTests` was **hollow**: it enumerated a hand-written `TheoryData` that had
already drifted (omitting Galaxy, Sql and Mqtt), so it guarded against renames but not gaps — a new
`DriverTypeNames` constant would simply never appear. Now enumerated from the map, paired with a
coverage test in the other direction.
**Live-verified on docker-dev** (`localhost:9200`, central pair rebuilt), because this repo has no bUnit
and no unit test can cover Blazor parameter binding: opened Calculation's config — the form renders where
it previously showed "No typed config form" — typed `3500`, saved, reopened, and the value **persisted**,
proving the `DynamicComponent` two-way binding round-trips. Sql's form renders fully (regression check on
the conversion) and now reads "This driver holds a single connection, authored above" (G-3). The rebuilt
image also showed `RediscoveryNeededUtc`/`RediscoveryReason` on the wire, confirming §8.2 end-to-end.
### 2026-07-27 — §8.3 #516 driver config edits silently discarded ### 2026-07-27 — §8.3 #516 driver config edits silently discarded
Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the
@@ -19,9 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these /// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
/// constants and the set the driver factories actually register, so a rename on /// constants and the set the driver factories actually register, so a rename on
/// either side breaks the build's test gate. Only constants for /// either side breaks the build's test gate. Only constants for
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver /// <b>currently-registered</b> factories belong here — a driver adds its own constant
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own /// when its factory is wired in. (This used to name Calculation as the example of a
/// constant when its factory is wired in. /// not-yet-registered driver; it registers in <c>DriverFactoryBootstrap</c> like the rest.)
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class DriverTypeNames public static class DriverTypeNames
@@ -153,7 +153,19 @@ internal sealed class SqlBrowseSession : IBrowseSession
Name: column.Name, Name: column.Name,
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(), DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
IsArray: false, IsArray: false,
SecurityClass: ReadOnlySecurityClass), SecurityClass: ReadOnlySecurityClass,
IsAlarm: false,
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
{
["schema"] = reference.Schema,
["table"] = reference.Table!,
["columnName"] = column.Name,
}),
]; ];
} }
@@ -39,38 +39,23 @@
</div> </div>
</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)
{
<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
{ {
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> <div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
} }
<div class="mt-3"> <div class="mt-3">
@@ -120,6 +105,14 @@
private string _name = ""; private string _name = "";
private bool _enabled = true; private bool _enabled = true;
private string _deviceConfigJson = "{}"; 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 byte[] _rowVersion = [];
private string _parentDriverConfig = "{}"; private string _parentDriverConfig = "{}";
@@ -31,50 +31,25 @@
</div> </div>
</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)
{
<DynamicComponent Type="formType" Parameters="_formParameters" />
}
else
{ {
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> <div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
} }
@* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and @* Some drivers hold ONE connection per driver instance and author it here; the rest
their device forms are informational. Every other driver splits the endpoint onto split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is
the device (the v3 endpoint→DeviceConfig split). *@ DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and
@if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt) 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"> <p class="form-text mt-3 mb-0">
This driver holds a single connection, authored above. Test-connect lives on its This driver holds a single connection, authored above. Test-connect lives on its
@@ -133,6 +108,17 @@
private string _driverType = ""; private string _driverType = "";
private string _name = ""; private string _name = "";
private string _driverConfigJson = "{}"; 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 string? _resilienceConfig;
private byte[] _rowVersion = []; 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; }
}
}
@@ -1,7 +1,6 @@
@* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller @* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller
creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via
the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now the Configure-driver modal. Name is inline-validated as a RawPath segment. Markup mirrors the other
(its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other
/raw modal shells. *@ /raw modal shells. *@
@using ZB.MOM.WW.OtOpcUa.Commons.Types @using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions @using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@@ -56,8 +55,8 @@
/// <summary>Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after.</summary> /// <summary>Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after.</summary>
[Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; } [Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; }
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway". Guarded against
// authorable now though its factory arrives in Wave C. // DriverTypeNames by RawDriverTypeDialogParityTests, which reads this field by reflection.
private static readonly (string Label, string Value)[] Types = private static readonly (string Label, string Value)[] Types =
[ [
("Modbus", DriverTypeNames.Modbus), ("Modbus", DriverTypeNames.Modbus),
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper
return new MTConnectTagConfigModel { FullName = address }.ToJson(); return new MTConnectTagConfigModel { FullName = address }.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy)) if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address); 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 // 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); return WriteSingleKey("address", address);
} }
@@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper
private static bool Is(string driverType, string name) private static bool Is(string driverType, string name)
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase); => 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) private static string WriteSingleKey(string key, string value)
{ {
var o = new JsonObject { [key] = value }; var o = new JsonObject { [key] = value };
@@ -319,9 +319,11 @@ public static class CsvColumnMap
public static IReadOnlyList<string> CommonColumns { get; } = public static IReadOnlyList<string> CommonColumns { get; } =
[.. CommonLeadingColumns, TagConfigJsonColumn]; [.. CommonLeadingColumns, TagConfigJsonColumn];
/// <summary>The Calculation (virtual-tag) driver-type string. Not yet a <see cref="DriverTypeNames"/> /// <summary>The Calculation (virtual-tag) driver-type string.</summary>
/// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface.</summary> /// <remarks>Retained as an alias for <see cref="DriverTypeNames.Calculation"/>, which now exists — the
public const string CalculationDriverType = "Calculation"; /// original note here ("not yet a DriverTypeNames constant; its factory lands in a later Batch-2
/// package") went stale once the factory registered.</remarks>
public const string CalculationDriverType = DriverTypeNames.Calculation;
private static readonly IReadOnlyDictionary<string, CsvDriverTagMap> Maps = BuildMaps(); private static readonly IReadOnlyDictionary<string, CsvDriverTagMap> Maps = BuildMaps();
@@ -449,6 +451,46 @@ public static class CsvColumnMap
new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool), new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool),
new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int), new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int),
}), }),
// deferment.md G-5 — Sql, Mqtt and MTConnect all have typed tag editors but had NO CSV map, so
// import/export silently degraded them to the raw TagConfigJson fallback while the seven older
// drivers got typed columns. Raw-key maps (as Galaxy and Calculation use) rather than
// model-backed ones: they address the identity keys directly, which is what a CSV round-trip
// needs, without duplicating each model's full accessor surface.
new RawKeyCsvDriverTagMap(DriverTypeNames.Sql, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("Model", "model", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("Table", "table", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("KeyColumn", "keyColumn", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("KeyValue", "keyValue", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("ValueColumn", "valueColumn", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("ColumnName", "columnName", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("SqlType", "type", null), CsvRawKeyKind.String),
}),
new RawKeyCsvDriverTagMap(DriverTypeNames.Mqtt, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("Mode", "mode", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("Topic", "topic", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("PayloadFormat", "payloadFormat", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("JsonPath", "jsonPath", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MqttDataType", "dataType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("GroupId", "groupId", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("EdgeNodeId", "edgeNodeId", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MqttDeviceId", "deviceId", null), CsvRawKeyKind.String),
}),
new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[]
{
new CsvRawKeyColumn(new CsvTypedColumn("DataItemId", "fullName", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtDataType", "dataType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtCategory", "mtCategory", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtType", "mtType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtSubType", "mtSubType", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtUnits", "units", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String),
new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String),
}),
}; };
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase); return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
@@ -22,9 +22,9 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared // Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests // note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql. // the factory" — went stale when the constant landed; the key is correct either way.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor), [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor), [DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
@@ -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)}");
}
}
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Parity guards for the two remaining per-driver dispatch maps (<c>deferment.md</c> G-5, G-6).
/// Both were already testable structures — unlike the two Razor <c>@switch</c> blocks that needed
/// extracting first — so nothing but the tests was missing.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverDispatchMapParityTests
{
/// <summary>
/// Driver types with a typed tag editor but no CSV map, so import/export silently degrades them to
/// the raw <c>TagConfigJson</c> fallback. Empty — every editor-backed driver now has typed columns.
/// <para>Deliberately expressed as "has a typed editor ⇒ has typed CSV columns" rather than
/// "every driver type", because a driver with no typed editor has nothing to project into columns.
/// Tying the two maps together is what makes this catch the next omission.</para>
/// </summary>
[Fact]
public void Every_driver_with_a_typed_tag_editor_has_typed_csv_columns()
{
var missing = DriverTypeNames.All
.Where(t => TagConfigEditorMap.Resolve(t) is not null && CsvColumnMap.Resolve(t) is null)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
$"these driver types have a typed tag editor but no CsvColumnMap entry, so CSV import/export "
+ $"drops them to the raw TagConfigJson fallback: {string.Join(", ", missing)}");
}
/// <summary>A CSV map for a driver type that no longer exists is a stale entry.</summary>
[Fact]
public void Every_csv_map_targets_a_declared_driver_type()
{
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
var stale = CsvColumnMap.All
.Select(m => m.DriverType)
.Where(t => !declared.Contains(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
stale.ShouldBeEmpty($"CsvColumnMap has entries for undeclared driver types: {string.Join(", ", stale)}");
}
/// <summary>
/// Every driver type either produces a TYPED blob from browse-commit, or is on the documented
/// non-browsable list. The generic <c>{"address": …}</c> fallback opens EMPTY in a typed editor and
/// blanks the field on save — the MTConnect branch was added for exactly that, and Sql had the same
/// bug unnoticed (<c>deferment.md</c> G-6) because the fallback's comment claimed "browsable drivers
/// are all handled above", which stopped being true the moment <c>SqlDriverBrowser</c> registered.
/// <para><b>Asserted in both directions on purpose.</b> A one-way check would let a newly-browsable
/// driver be quietly added to the exclusion list instead of getting a branch. Requiring the two sets
/// to match exactly means a driver gaining or losing a browser forces this list to be revisited.</para>
/// </summary>
[Fact]
public void Browse_commit_falls_back_to_the_generic_address_key_for_exactly_the_non_browsable_drivers()
{
// Address fields as a browser supplies them; a driver whose browser states none must still produce
// something its typed editor can read from the leaf name alone.
var addressFields = new Dictionary<string, string>(StringComparer.Ordinal)
{
["schema"] = "dbo",
["table"] = "Readings",
["columnName"] = "Speed",
};
var fellBack = DriverTypeNames.All
.Where(t => RawBrowseCommitMapper.BuildTagConfig(t, "Speed", addressFields) == """{"address":"Speed"}""")
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
fellBack.ShouldBe(
NonBrowsableDriverTypes.OrderBy(t => t, StringComparer.Ordinal).ToList(),
$"the set of driver types committing to the generic \"address\" key drifted. Extra entries are "
+ $"browsable drivers whose typed editor will open EMPTY and blank the field on save; missing "
+ $"entries gained a typed branch and should leave this list. Got: {string.Join(", ", fellBack)}");
}
/// <summary>
/// Driver types with no browser, for which the generic <c>address</c> key is the correct commit.
/// <list type="bullet">
/// <item><b>Modbus</b> — register addresses are numeric coordinates, not a browsable namespace.</item>
/// <item><b>Calculation</b> — a pseudo-driver over other tags' RawPaths; there is no device.</item>
/// </list>
/// Adding a type here is a claim that it cannot be browsed. If it can, give it a branch instead.
/// </summary>
private static readonly string[] NonBrowsableDriverTypes =
[
DriverTypeNames.Modbus,
DriverTypeNames.Calculation,
];
}
@@ -17,18 +17,43 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class TagConfigDriverTypeNameGuardTests public sealed class TagConfigDriverTypeNameGuardTests
{ {
// Every DriverTypeNames constant that has a typed editor registered. // Every DriverTypeNames constant that has a typed editor registered.
public static TheoryData<string> EditorMappedDriverTypes() => //
[ // Enumerated FROM the map rather than hand-written. The hand-written list this replaced guarded
DriverTypeNames.Modbus, // against RENAMES but not against GAPS: a newly-added DriverTypeNames constant simply never appeared
DriverTypeNames.S7, // here, so the guard stayed green while the map went unmapped — and it had already silently drifted,
DriverTypeNames.AbCip, // omitting Galaxy, Sql and Mqtt. Pairing it with the coverage test below makes both directions real.
DriverTypeNames.AbLegacy, public static TheoryData<string> EditorMappedDriverTypes()
DriverTypeNames.TwinCAT, {
DriverTypeNames.FOCAS, var data = new TheoryData<string>();
DriverTypeNames.OpcUaClient, foreach (var t in DriverTypeNames.All.Where(t => TagConfigEditorMap.Resolve(t) is not null))
DriverTypeNames.Calculation, {
DriverTypeNames.MTConnect, data.Add(t);
]; }
return data;
}
/// <summary>
/// Coverage direction: every declared driver type has a typed tag editor, except those documented as
/// having none. Without this, enumerating the map above proves only that the map agrees with itself.
/// </summary>
[Fact]
public void Every_declared_driver_type_has_a_typed_tag_editor_or_is_documented_as_raw_json()
{
// Galaxy authors its tag reference as a dotted FullName through the Galaxy address picker rather
// than a typed field editor, so it uses the generic raw-TagConfig-JSON textarea by design.
string[] rawJsonByDesign = [DriverTypeNames.Galaxy];
var missing = DriverTypeNames.All
.Where(t => TagConfigEditorMap.Resolve(t) is null)
.Except(rawJsonByDesign, StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
$"these driver types have no typed tag editor and are not documented as raw-JSON by design, so "
+ $"the /uns TagModal falls back to a bare JSON textarea for them: {string.Join(", ", missing)}");
}
[Theory] [Theory]
[MemberData(nameof(EditorMappedDriverTypes))] [MemberData(nameof(EditorMappedDriverTypes))]