92ba120964
The P1 live gate had to insert MQTT driver config directly via SQL: DriverConfigModal and DeviceModal both fell through to "No typed config form for driver type Mqtt" with no raw-JSON fallback, so broker host/port/TLS/credentials were unauthorable from the AdminUI. Task 12 built the *tag* editor; these are the driver + device surfaces. MqttDriverForm authors the whole MqttDriverOptions connection surface (host/port/clientId, TLS + CA pin, credentials, protocol version/clean-session/keep-alive, connect timeout, reconnect backoffs, mode, maxPayloadBytes, and the Plain sub-object). The Sparkplug sub-object stays P2 — a marked placeholder mirroring MqttTagConfigEditor's stub, with any existing sparkplug keys preserved untouched. The connection is authored on the DRIVER, not the device — unlike Modbus/S7/OpcUaClient and contrary to what docs/drivers/Mqtt.md said. DriverDeviceConfigMerger merges a device's keys up only when the driver has exactly ONE device, so a device-authored broker connection vanishes silently the moment a second device is added. MqttDeviceForm is therefore informational (the GalaxyDeviceForm shape) and round-trips DeviceConfig verbatim; it flags legacy connection keys left on a device by a pre-form deployment, because those still win via merge-up. Serialization goes through the ONE shared MqttJson.Options instance (Task 9's decision), never a fifth per-form copy — pinned by an assertion that an ordinal-only reader CANNOT bind the emitted blob, and by extending the fleet-wide DriverPageJsonConverterTests guard to resolve a form's serializer from an explicit external-instance registry when it has no _jsonOpts field. Dropping the converter from MqttJson.Options reddens 3 tests and leaves the symmetric round-trip green — the Task 9 lesson, reproduced. RawTags is stripped from the emitted blob (the deploy artifact owns it) and unknown top-level keys survive a load->save. Validation mirrors the driver's own [Range] bounds inline, and ToOptions() additionally clamps, so an ignored error still cannot persist a connectTimeoutSeconds:0 driver-brick. A non-blank clientId raises the P1 live-gate warning inline (fixed ids make redundant pair nodes evict each other while both report Healthy). AdminUI 761/761 green (was 730), TWAE-clean; Driver.Mqtt 266/266 green. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
107 lines
6.0 KiB
C#
107 lines
6.0 KiB
C#
using System.Reflection;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19.
|
|
/// Every driver-config editor serialised enum config fields (e.g. S7 <c>CpuType</c>, Modbus
|
|
/// <c>Family</c>, AbCip <c>PlcFamily</c>) as JSON <em>numbers</em> when its private static
|
|
/// <c>_jsonOpts</c> had no <see cref="JsonStringEnumConverter"/>. The driver factories, however,
|
|
/// deserialise into string-typed DTOs (+ lenient <c>ParseEnum</c>) and <em>throw</em> when binding a
|
|
/// JSON number to a <c>string?</c> — so an AdminUI-authored config that contained any enum field
|
|
/// produced a blob the driver could not parse, faulting the driver on deploy. The fix makes every
|
|
/// editor serialise enums as strings (matching the factory + the long-correct OpcUaClient template).
|
|
/// <para>v3 Batch-2 (WP3): the config serializer moved from the routed <c>*DriverPage</c> into the
|
|
/// embeddable <c>*DriverForm</c> body (shared by the page + the raw-tree DriverConfigModal), so this
|
|
/// guard now reflects over the <c>*DriverForm</c> fleet. It fails if any driver form loses its
|
|
/// string-enum converter again.</para>
|
|
/// </summary>
|
|
public sealed class DriverPageJsonConverterTests
|
|
{
|
|
/// <summary>
|
|
/// Driver forms whose config serializer does NOT live in a private static <c>_jsonOpts</c> field
|
|
/// on the form, mapped to the options instance they actually use. Each entry is an explicit,
|
|
/// reviewed exception — the guard below still asserts the converter on whatever instance is
|
|
/// named here, so this narrows <em>where</em> the options live, never <em>whether</em> they are
|
|
/// checked.
|
|
/// <para><c>MqttDriverForm</c>: the MQTT driver deliberately keeps <b>one</b>
|
|
/// <see cref="JsonSerializerOptions"/> for all five of its config seams (runtime factory,
|
|
/// Test-connect probe, driver re-parse, address-picker browser, this form) in
|
|
/// <c>MqttJson.Options</c> — a per-form copy would be a fifth divergent instance, i.e. exactly
|
|
/// the bug this whole file guards. The form itself is a shell over the pure
|
|
/// <c>MqttDriverFormModel</c>, which serialises through that shared instance.</para>
|
|
/// </summary>
|
|
private static IReadOnlyDictionary<Type, JsonSerializerOptions> ExternalConfigSerializers { get; } =
|
|
new Dictionary<Type, JsonSerializerOptions>
|
|
{
|
|
[typeof(MqttDriverForm)] = MqttJson.Options,
|
|
};
|
|
|
|
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly.</summary>
|
|
private static IReadOnlyList<Type> DriverFormTypes { get; } =
|
|
typeof(ModbusDriverForm).Assembly.GetTypes()
|
|
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
|
|
.OrderBy(t => t.Name)
|
|
.ToList();
|
|
|
|
/// <summary>Resolves a form's config serializer — its own <c>_jsonOpts</c> field, else the
|
|
/// explicitly-registered external instance — or <c>null</c> when it has neither.</summary>
|
|
/// <param name="formType">A driver form component type.</param>
|
|
/// <returns>The options the form serialises config through, or <c>null</c>.</returns>
|
|
private static JsonSerializerOptions? ResolveConfigSerializer(Type formType)
|
|
{
|
|
if (formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is { } field)
|
|
{
|
|
return (JsonSerializerOptions?)field.GetValue(null);
|
|
}
|
|
return ExternalConfigSerializers.GetValueOrDefault(formType);
|
|
}
|
|
|
|
/// <summary>xUnit theory source over the driver-form types discovered by reflection.</summary>
|
|
public static TheoryData<Type> DriverFormsWithJsonOpts()
|
|
{
|
|
var data = new TheoryData<Type>();
|
|
foreach (var t in DriverFormTypes)
|
|
data.Add(t);
|
|
return data;
|
|
}
|
|
|
|
/// <summary>Verifies every driver form's config serializer registers a string-enum converter so
|
|
/// enum config fields round-trip as strings (and the driver factory can parse the result).</summary>
|
|
/// <param name="formType">A driver form component type discovered by reflection.</param>
|
|
[Theory]
|
|
[MemberData(nameof(DriverFormsWithJsonOpts))]
|
|
public void Driver_form_json_options_register_string_enum_converter(Type formType)
|
|
{
|
|
var opts = ResolveConfigSerializer(formType);
|
|
opts.ShouldNotBeNull(
|
|
$"{formType.Name} must serialise config through a private static _jsonOpts field, or be listed in " +
|
|
"ExternalConfigSerializers with the shared options instance it uses — otherwise its enum handling " +
|
|
"is unguarded.");
|
|
opts.Converters.OfType<JsonStringEnumConverter>().ShouldNotBeEmpty(
|
|
$"{formType.Name}'s config serializer must register a JsonStringEnumConverter; otherwise " +
|
|
"AdminUI-authored enum config fields serialise as numbers and the string-typed driver factory " +
|
|
"throws on parse.");
|
|
}
|
|
|
|
/// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a
|
|
/// serializer the guard above can reach — otherwise a new form that serialised config a different way
|
|
/// would slip past it. Also a floor check so a rename can't silently shrink the set.</summary>
|
|
[Fact]
|
|
public void Every_driver_form_uses_a_guarded_jsonOpts_serializer()
|
|
{
|
|
DriverFormTypes.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-form fleet");
|
|
|
|
var unguarded = DriverFormTypes.Where(t => ResolveConfigSerializer(t) is null).Select(t => t.Name).ToList();
|
|
unguarded.ShouldBeEmpty(
|
|
"every *DriverForm must expose its config serializer (a _jsonOpts field, or an ExternalConfigSerializers " +
|
|
"entry) so the string-enum converter guard covers it");
|
|
}
|
|
}
|