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:
+46
-3
@@ -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)
|
||||||
{
|
{
|
||||||
case DriverTypeNames.Modbus:
|
<DynamicComponent Type="deviceFormType" Parameters="_deviceFormParameters" />
|
||||||
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
}
|
||||||
break;
|
else if (DeviceFormMap.IsSingleConnection(_driverType))
|
||||||
case DriverTypeNames.S7:
|
{
|
||||||
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
<div class="alert alert-info">
|
||||||
break;
|
This driver holds a single connection, authored on the <strong>driver</strong>.
|
||||||
case DriverTypeNames.AbCip:
|
There is nothing to configure per device.
|
||||||
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
</div>
|
||||||
break;
|
}
|
||||||
case DriverTypeNames.AbLegacy:
|
else
|
||||||
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
{
|
||||||
break;
|
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<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 = "{}";
|
||||||
|
|
||||||
|
|||||||
+27
-41
@@ -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)
|
||||||
{
|
{
|
||||||
case DriverTypeNames.Modbus:
|
<DynamicComponent Type="formType" Parameters="_formParameters" />
|
||||||
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
}
|
||||||
break;
|
else
|
||||||
case DriverTypeNames.S7:
|
{
|
||||||
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@* 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><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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-4
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
+37
-12
@@ -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))]
|
||||||
|
|||||||
Reference in New Issue
Block a user