diff --git a/deferment.md b/deferment.md index 4c29ad33..47f063e7 100644 --- a/deferment.md +++ b/deferment.md @@ -355,8 +355,9 @@ appear in §7.1 as well. 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.~~ ✅ **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 - have caught G-1 and G-2 for free, and this class has now recurred twice. +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.~~ ✅ **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 (§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 @@ -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` | | 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` | -| 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 | — | | 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 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 ``, 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 Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs index 4ae116f1..b08039a4 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs @@ -19,9 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// (DriverTypeNamesGuardTests) asserts bidirectional parity between these /// constants and the set the driver factories actually register, so a rename on /// either side breaks the build's test gate. Only constants for -/// currently-registered factories belong here — a not-yet-registered driver -/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own -/// constant when its factory is wired in. +/// currently-registered factories belong here — a driver adds its own constant +/// when its factory is wired in. (This used to name Calculation as the example of a +/// not-yet-registered driver; it registers in DriverFactoryBootstrap like the rest.) /// /// public static class DriverTypeNames diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs index 06c05d8f..ff397fcd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs @@ -153,7 +153,19 @@ internal sealed class SqlBrowseSession : IBrowseSession Name: column.Name, DriverDataType: _dialect.MapColumnType(column.DataType).ToString(), 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(StringComparer.Ordinal) + { + ["schema"] = reference.Schema, + ["table"] = reference.Table!, + ["columnName"] = column.Name, + }), ]; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor index 8d8d851e..52db7687 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor @@ -39,38 +39,23 @@ - @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: - - break; - case DriverTypeNames.S7: - - break; - case DriverTypeNames.AbCip: - - break; - case DriverTypeNames.AbLegacy: - - break; - case DriverTypeNames.TwinCAT: - - break; - case DriverTypeNames.FOCAS: - - break; - case DriverTypeNames.OpcUaClient: - - break; - case DriverTypeNames.Galaxy: - - break; - case DriverTypeNames.Mqtt: - - break; - default: -
No typed device form for driver type @_driverType.
- break; + + } + else if (DeviceFormMap.IsSingleConnection(_driverType)) + { +
+ This driver holds a single connection, authored on the driver. + There is nothing to configure per device. +
+ } + else + { +
No typed device form for driver type @_driverType.
}
@@ -120,6 +105,14 @@ private string _name = ""; private bool _enabled = true; private string _deviceConfigJson = "{}"; + + /// Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render — + /// see DriverConfigModal for why this must not be cached. + private Dictionary _deviceFormParameters => new() + { + ["DeviceConfigJson"] = _deviceConfigJson, + ["DeviceConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _deviceConfigJson = v), + }; private byte[] _rowVersion = []; private string _parentDriverConfig = "{}"; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor index 5fa88406..376aeb4d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor @@ -31,50 +31,25 @@
- @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: - - break; - case DriverTypeNames.S7: - - break; - case DriverTypeNames.AbCip: - - break; - case DriverTypeNames.AbLegacy: - - break; - case DriverTypeNames.TwinCAT: - - break; - case DriverTypeNames.FOCAS: - - break; - case DriverTypeNames.OpcUaClient: - - break; - case DriverTypeNames.Galaxy: - - break; - case DriverTypeNames.Sql: - - break; - case DriverTypeNames.Mqtt: - - break; - case DriverTypeNames.MTConnect: - - break; - default: -
No typed config form for driver type @_driverType.
- break; + + } + else + { +
No typed config form for driver type @_driverType.
} - @* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and - their device forms are informational. Every other driver splits the endpoint onto - the device (the v3 endpoint→DeviceConfig split). *@ - @if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt) + @* Some drivers hold ONE connection per driver instance and author it here; the rest + split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is + DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and + so told Sql and MTConnect authors to go find an endpoint field on a device form that + does not exist (deferment.md G-3). *@ + @if (DeviceFormMap.IsSingleConnection(_driverType)) {

This driver holds a single connection, authored above. Test-connect lives on its @@ -133,6 +108,17 @@ private string _driverType = ""; private string _name = ""; private string _driverConfigJson = "{}"; + + ///

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. + private Dictionary _formParameters => new() + { + ["DriverConfigJson"] = _driverConfigJson, + ["DriverConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _driverConfigJson = v), + ["ResilienceConfig"] = _resilienceConfig!, + ["ResilienceConfigChanged"] = EventCallback.Factory.Create(this, v => _resilienceConfig = v), + }; private string? _resilienceConfig; private byte[] _rowVersion = []; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs new file mode 100644 index 00000000..3357f68d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverFormMaps.cs @@ -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; + +/// +/// DriverType → typed driver-config form component, rendered by DriverConfigModal through +/// <DynamicComponent>. +/// Why this is a map and not the @switch it replaced. A Razor @switch compiles +/// into BuildRenderTree's IL, so no test can enumerate its cases — which is exactly how the modal +/// came to be missing a Calculation arm while the sibling driver-picker guard stayed green +/// (deferment.md G-1/G-4). The picker test only works because RawDriverTypeDialog keeps its +/// data in a field the markup enumerates; this type gives the two config modals the same property, and +/// being public it needs none of that test's BindingFlags.NonPublic fragility. +/// Modelled on TagConfigEditorMap. Guarded by DriverFormMapParityTests. +/// +public static class DriverConfigFormMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(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), + }; + + /// The driver types that have a typed config form. + public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Map.Keys; + + /// 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). + /// The driver type name. + /// The form component type, or . + public static Type? Resolve(string? driverType) => + driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; +} + +/// +/// DriverType → typed device-config form component, rendered by DeviceModal. +/// Not every driver has one, by design. The types in +/// 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. +/// +public static class DeviceFormMap +{ + private static readonly IReadOnlyDictionary Map = + new Dictionary(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), + }; + + /// + /// 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. + /// Read by DriverConfigModal 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 (deferment.md G-3). + /// + public static readonly IReadOnlySet SingleConnectionDriverTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + DriverTypeNames.Galaxy, + DriverTypeNames.Mqtt, + DriverTypeNames.Sql, + DriverTypeNames.MTConnect, + DriverTypeNames.Calculation, + }; + + /// The driver types that have a typed device form. + public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Map.Keys; + + /// Resolves the device form component for a driver type, or null when none is mapped. + /// The driver type name. + /// The form component type, or . + public static Type? Resolve(string? driverType) => + driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null; + + /// True when this driver authors its connection on the DRIVER form rather than per-device. + /// The driver type name. + /// when the connection is driver-level. + public static bool IsSingleConnection(string? driverType) => + driverType is not null && SingleConnectionDriverTypes.Contains(driverType); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor new file mode 100644 index 00000000..33d5a278 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/CalculationDriverForm.razor @@ -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 + +
+
Evaluation
+
+
+
+ + +
+ 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. +
+
+
+

+ 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 /raw. +

+
+
+ + + +@code { + /// The driver-level DriverConfig JSON. + [Parameter] public string DriverConfigJson { get; set; } = "{}"; + /// Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson. + [Parameter] public EventCallback DriverConfigJsonChanged { get; set; } + /// The per-instance resilience-pipeline overrides JSON, or null. + [Parameter] public string? ResilienceConfig { get; set; } + /// Fired when the resilience overrides change. + [Parameter] public EventCallback 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; + } + } + + /// Serializes the current config to camelCase JSON. rawTags is never emitted — the composer + /// adds it at deploy time. + 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(json, _jsonOpts); } + catch (JsonException) { return null; } // a hand-edited blob must not crash the modal + } + + /// 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. + private sealed class Dto + { + public int? RunTimeoutMs { get; init; } + } + + private sealed class FormModel + { + public int? RunTimeoutMs { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor index 510a97b1..a14c7140 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor @@ -1,7 +1,6 @@ @* 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 - the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now - (its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other + the Configure-driver modal. Name is inline-validated as a RawPath segment. Markup mirrors the other /raw modal shells. *@ @using ZB.MOM.WW.OtOpcUa.Commons.Types @using ZB.MOM.WW.OtOpcUa.Core.Abstractions @@ -56,8 +55,8 @@ /// Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after. [Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; } - // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is - // authorable now though its factory arrives in Wave C. + // Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway". Guarded against + // DriverTypeNames by RawDriverTypeDialogParityTests, which reads this field by reflection. private static readonly (string Label, string Value)[] Types = [ ("Modbus", DriverTypeNames.Modbus), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs index 49c48764..43fde69d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs @@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; @@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper return new MTConnectTagConfigModel { FullName = address }.ToJson(); if (Is(driverType, DriverTypeNames.Galaxy)) 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 - // "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); } @@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper private static bool Is(string driverType, string name) => string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase); + /// + /// Builds a SqlTagConfigModel blob from a browsed COLUMN leaf. Maps to + /// SqlTagModel.WideRow — 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. + /// 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. + /// + private static string BuildSqlTagConfig(string address, IReadOnlyDictionary? 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) { var o = new JsonObject { [key] = value }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs index aa5e71af..74ba100f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -319,9 +319,11 @@ public static class CsvColumnMap public static IReadOnlyList CommonColumns { get; } = [.. CommonLeadingColumns, TagConfigJsonColumn]; - /// The Calculation (virtual-tag) driver-type string. Not yet a - /// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface. - public const string CalculationDriverType = "Calculation"; + /// The Calculation (virtual-tag) driver-type string. + /// Retained as an alias for , which now exists — the + /// original note here ("not yet a DriverTypeNames constant; its factory lands in a later Batch-2 + /// package") went stale once the factory registered. + public const string CalculationDriverType = DriverTypeNames.Calculation; private static readonly IReadOnlyDictionary Maps = BuildMaps(); @@ -449,6 +451,46 @@ public static class CsvColumnMap new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool), 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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 11adc332..978e4d9c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -22,9 +22,9 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), - // Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared - // constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests - // asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql. + // Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The + // note that used to sit here — "that shared constant is deliberately absent until Task 11 wires + // the factory" — went stale when the constant landed; the key is correct either way. [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor), [DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs new file mode 100644 index 00000000..f9d6e8a5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Drivers/DriverFormMapParityTests.cs @@ -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; + +/// +/// Closes deferment.md G-4. A guard already existed for DriverTypeNames ↔ the /raw +/// 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 +/// Calculation (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2). Both survived +/// review because nothing could see them. +/// These are ordinary map lookups rather than reflection over a private field: the two modals were +/// converted from a Razor @switch — unreachable from a test, since it compiles into +/// BuildRenderTree's IL — to DriverConfigFormMap / DeviceFormMap. There is no bUnit +/// in this project, so a map is the only thing a test can hold on to. +/// +[Trait("Category", "Unit")] +public sealed class DriverFormMapParityTests +{ + /// 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. + [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)}"); + } + + /// 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. + [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)}"); + } + + /// + /// Every declared driver type must EITHER have a typed device form OR be declared single-connection. + /// 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. + /// + [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)}"); + } + + /// The reverse direction for the device map. + [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)}"); + } + + /// + /// 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. + /// + [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)}"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs new file mode 100644 index 00000000..0cb9f9ff --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/DriverDispatchMapParityTests.cs @@ -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; + +/// +/// Parity guards for the two remaining per-driver dispatch maps (deferment.md G-5, G-6). +/// Both were already testable structures — unlike the two Razor @switch blocks that needed +/// extracting first — so nothing but the tests was missing. +/// +[Trait("Category", "Unit")] +public sealed class DriverDispatchMapParityTests +{ + /// + /// Driver types with a typed tag editor but no CSV map, so import/export silently degrades them to + /// the raw TagConfigJson fallback. Empty — every editor-backed driver now has typed columns. + /// 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. + /// + [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)}"); + } + + /// A CSV map for a driver type that no longer exists is a stale entry. + [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)}"); + } + + /// + /// Every driver type either produces a TYPED blob from browse-commit, or is on the documented + /// non-browsable list. The generic {"address": …} 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 (deferment.md G-6) because the fallback's comment claimed "browsable drivers + /// are all handled above", which stopped being true the moment SqlDriverBrowser registered. + /// Asserted in both directions on purpose. 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. + /// + [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(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)}"); + } + + /// + /// Driver types with no browser, for which the generic address key is the correct commit. + /// + /// Modbus — register addresses are numeric coordinates, not a browsable namespace. + /// Calculation — a pseudo-driver over other tags' RawPaths; there is no device. + /// + /// Adding a type here is a claim that it cannot be browsed. If it can, give it a branch instead. + /// + private static readonly string[] NonBrowsableDriverTypes = + [ + DriverTypeNames.Modbus, + DriverTypeNames.Calculation, + ]; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs index cc581114..0219fc2e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs @@ -17,18 +17,43 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; public sealed class TagConfigDriverTypeNameGuardTests { // Every DriverTypeNames constant that has a typed editor registered. - public static TheoryData EditorMappedDriverTypes() => - [ - DriverTypeNames.Modbus, - DriverTypeNames.S7, - DriverTypeNames.AbCip, - DriverTypeNames.AbLegacy, - DriverTypeNames.TwinCAT, - DriverTypeNames.FOCAS, - DriverTypeNames.OpcUaClient, - DriverTypeNames.Calculation, - DriverTypeNames.MTConnect, - ]; + // + // Enumerated FROM the map rather than hand-written. The hand-written list this replaced guarded + // against RENAMES but not against GAPS: a newly-added DriverTypeNames constant simply never appeared + // here, so the guard stayed green while the map went unmapped — and it had already silently drifted, + // omitting Galaxy, Sql and Mqtt. Pairing it with the coverage test below makes both directions real. + public static TheoryData EditorMappedDriverTypes() + { + var data = new TheoryData(); + foreach (var t in DriverTypeNames.All.Where(t => TagConfigEditorMap.Resolve(t) is not null)) + { + data.Add(t); + } + + return data; + } + + /// + /// 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. + /// + [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] [MemberData(nameof(EditorMappedDriverTypes))]