feat(adminui): B2-WP3 driver/device config modals + page-to-form refactor
Extract the 8 typed driver pages into embeddable <Driver>DriverForm bodies (channel/protocol config) and add per-driver <Driver>DeviceForm bodies (connection endpoint -> Device.DeviceConfig, v3 endpoint split). New DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device config transiently via DriverDeviceConfigMerger. Routed pages now host the form bodies and keep working. Round-trip + enum-serialization guard tests for the Modbus driver form + Modbus device model; retargeted the existing page-form serialization tests + the _jsonOpts converter guard to the forms. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
+7
-46
@@ -2,7 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
@@ -79,59 +79,20 @@ public sealed class AbCipDriverPageFormSerializationTests
|
||||
back.ProbeTimeoutSeconds.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_round_trips_through_definition()
|
||||
{
|
||||
var row = new AbCipDriverPage.AbCipDeviceRow
|
||||
{
|
||||
HostAddress = "ab://10.0.0.1/1,0", PlcFamily = AbCipPlcFamily.CompactLogix, DeviceName = "PLC-A",
|
||||
};
|
||||
var def = row.ToDefinition();
|
||||
var back = AbCipDriverPage.AbCipDeviceRow.FromDefinition(def);
|
||||
|
||||
back.HostAddress.ShouldBe("ab://10.0.0.1/1,0");
|
||||
back.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
|
||||
back.DeviceName.ShouldBe("PLC-A");
|
||||
}
|
||||
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + AbCipDeviceRow retired — devices are now
|
||||
// separate Device entities authored via the raw-tree DeviceModal (AbCipDeviceModel, HostAddress/PlcFamily
|
||||
// into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config
|
||||
// FormModel now lives in AbCipDriverForm; the multi-device Devices array it preserves is covered below.
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_preserves_unedited_fields()
|
||||
{
|
||||
var original = new AbCipDeviceOptions(
|
||||
"ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-A",
|
||||
AllowPacking: true, ConnectionSize: 4002);
|
||||
var row = AbCipDriverPage.AbCipDeviceRow.FromDefinition(original);
|
||||
row.HostAddress = "ab://10.0.0.2/1,0";
|
||||
|
||||
var back = row.ToDefinition();
|
||||
back.HostAddress.ShouldBe("ab://10.0.0.2/1,0");
|
||||
back.AllowPacking.ShouldBe(true);
|
||||
back.ConnectionSize.ShouldBe(4002);
|
||||
}
|
||||
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (AbCipDriverPage.AbCipTagRow +
|
||||
// AbCipDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
|
||||
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
|
||||
// + the device-list serialization below, all carrying the AbCipPlcFamily enum) stays.
|
||||
|
||||
[Fact]
|
||||
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||
{
|
||||
var rows = new List<AbCipDriverPage.AbCipDeviceRow> { new() { HostAddress = "ab://10.0.0.1/1,0" } };
|
||||
AbCipDriverPage.AbCipDeviceRow.ValidateRow(new() { HostAddress = "ab://10.0.0.1/1,0" }, rows, null)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_list_survives_options_serialize_round_trip()
|
||||
public void Device_list_survives_form_serialize_round_trip()
|
||||
{
|
||||
var devices = new List<AbCipDeviceOptions>
|
||||
{
|
||||
new("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"),
|
||||
new("ab://10.0.0.2/1,0", AbCipPlcFamily.CompactLogix, "PLC-2"),
|
||||
};
|
||||
var opts = new AbCipDriverPage.FormModel().ToOptions(devices);
|
||||
var opts = new AbCipDriverForm.FormModel().ToOptions(devices);
|
||||
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||
var back = JsonSerializer.Deserialize<AbCipDriverOptions>(json, TestJsonOpts)!;
|
||||
back.Devices.Count.ShouldBe(2);
|
||||
|
||||
+7
-44
@@ -2,7 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
@@ -75,57 +75,20 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
back.ProbeTimeoutSeconds.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_round_trips_through_definition()
|
||||
{
|
||||
var row = new AbLegacyDriverPage.AbLegacyDeviceRow
|
||||
{
|
||||
HostAddress = "10.0.0.10", PlcFamily = AbLegacyPlcFamily.MicroLogix, DeviceName = "PLC-A",
|
||||
};
|
||||
var def = row.ToDefinition();
|
||||
var back = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(def);
|
||||
|
||||
back.HostAddress.ShouldBe("10.0.0.10");
|
||||
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
|
||||
back.DeviceName.ShouldBe("PLC-A");
|
||||
}
|
||||
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + AbLegacyDeviceRow retired — devices are now
|
||||
// separate Device entities authored via the raw-tree DeviceModal (AbLegacyDeviceModel, HostAddress/PlcFamily
|
||||
// into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config
|
||||
// FormModel now lives in AbLegacyDriverForm; the multi-device Devices array it preserves is covered below.
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_preserves_unedited_fields()
|
||||
{
|
||||
var original = new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Plc5, "PLC-A");
|
||||
var row = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(original);
|
||||
row.HostAddress = "10.0.0.20";
|
||||
|
||||
var back = row.ToDefinition();
|
||||
back.HostAddress.ShouldBe("10.0.0.20");
|
||||
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
|
||||
back.DeviceName.ShouldBe("PLC-A");
|
||||
}
|
||||
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (AbLegacyDriverPage.AbLegacyTagRow +
|
||||
// AbLegacyDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
|
||||
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
|
||||
// + the device-list serialization below, all carrying the AbLegacyPlcFamily enum) stays.
|
||||
|
||||
[Fact]
|
||||
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||
{
|
||||
var rows = new List<AbLegacyDriverPage.AbLegacyDeviceRow> { new() { HostAddress = "10.0.0.10" } };
|
||||
AbLegacyDriverPage.AbLegacyDeviceRow.ValidateRow(new() { HostAddress = "10.0.0.10" }, rows, null)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_list_survives_options_serialize_round_trip()
|
||||
public void Device_list_survives_form_serialize_round_trip()
|
||||
{
|
||||
var devices = new List<AbLegacyDeviceOptions>
|
||||
{
|
||||
new("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-1"),
|
||||
new("10.0.0.11", AbLegacyPlcFamily.MicroLogix, "PLC-2"),
|
||||
};
|
||||
var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices);
|
||||
var opts = new AbLegacyDriverForm.FormModel().ToOptions(devices);
|
||||
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||
var back = JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, TestJsonOpts)!;
|
||||
back.Devices.Count.ShouldBe(2);
|
||||
|
||||
@@ -3,67 +3,69 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19.
|
||||
/// Every <c>*DriverPage</c> serialised enum config fields (e.g. S7 <c>CpuType</c>, Modbus
|
||||
/// <c>DataType</c>/<c>Region</c>, AbCip <c>PlcFamily</c>) as JSON <em>numbers</em> because 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 page serialise enums as strings (matching the
|
||||
/// factory + the long-correct OpcUaClient template). This test fails if any driver page loses
|
||||
/// its string-enum converter again.
|
||||
/// 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>Every concrete <c>*DriverPage</c> in the AdminUI assembly that declares a
|
||||
/// <summary>Every concrete <c>*DriverForm</c> in the AdminUI assembly that declares a
|
||||
/// <c>_jsonOpts</c> config serializer.</summary>
|
||||
private static IReadOnlyList<Type> DriverPageTypes { get; } =
|
||||
typeof(S7DriverPage).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
private static IReadOnlyList<Type> DriverFormTypes { get; } =
|
||||
typeof(ModbusDriverForm).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToList();
|
||||
|
||||
/// <summary>xUnit theory source over the driver-page types discovered by reflection.</summary>
|
||||
public static TheoryData<Type> DriverPagesWithJsonOpts()
|
||||
/// <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 DriverPageTypes)
|
||||
foreach (var t in DriverFormTypes)
|
||||
data.Add(t);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Verifies every driver page's config serializer registers a string-enum converter so
|
||||
/// <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="pageType">A driver page component type discovered by reflection.</param>
|
||||
/// <param name="formType">A driver form component type discovered by reflection.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(DriverPagesWithJsonOpts))]
|
||||
public void Driver_page_json_options_register_string_enum_converter(Type pageType)
|
||||
[MemberData(nameof(DriverFormsWithJsonOpts))]
|
||||
public void Driver_form_json_options_register_string_enum_converter(Type formType)
|
||||
{
|
||||
var field = pageType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var field = formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var opts = (JsonSerializerOptions)field!.GetValue(null)!;
|
||||
opts.Converters.OfType<JsonStringEnumConverter>().ShouldNotBeEmpty(
|
||||
$"{pageType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " +
|
||||
$"{formType.Name}._jsonOpts 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>*DriverPage</c> routes config serialization through a
|
||||
/// <c>_jsonOpts</c> field — otherwise a new page that serialised config a different way would slip
|
||||
/// <summary>Enforces that EVERY concrete <c>*DriverForm</c> routes config serialization through a
|
||||
/// <c>_jsonOpts</c> field — otherwise a new form that serialised config a different way would slip
|
||||
/// past the converter guard above. Also a floor check so a rename can't silently shrink the set.</summary>
|
||||
[Fact]
|
||||
public void Every_driver_page_uses_a_guarded_jsonOpts_serializer()
|
||||
public void Every_driver_form_uses_a_guarded_jsonOpts_serializer()
|
||||
{
|
||||
var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
var allDriverForms = typeof(ModbusDriverForm).Assembly.GetTypes()
|
||||
.Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract)
|
||||
.ToList();
|
||||
allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-page fleet");
|
||||
DriverPageTypes.Count.ShouldBe(allDriverPages.Count,
|
||||
"every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
|
||||
allDriverForms.Count.ShouldBeGreaterThanOrEqualTo(8, "reflection should discover the full driver-form fleet");
|
||||
DriverFormTypes.Count.ShouldBe(allDriverForms.Count,
|
||||
"every *DriverForm must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
|
||||
}
|
||||
}
|
||||
|
||||
+8
-46
@@ -2,7 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
@@ -119,8 +119,7 @@ public sealed class FocasDriverPageFormSerializationTests
|
||||
},
|
||||
};
|
||||
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.FocasDriverPage.FormModel.FromOptions(opts);
|
||||
var form = FocasDriverForm.FormModel.FromOptions(opts);
|
||||
var roundTripped = form.ToOptions([]);
|
||||
|
||||
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
||||
@@ -138,57 +137,20 @@ public sealed class FocasDriverPageFormSerializationTests
|
||||
roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_round_trips_through_definition()
|
||||
{
|
||||
var row = new FocasDriverPage.FocasDeviceRow
|
||||
{
|
||||
HostAddress = "192.168.0.10:8193", Series = FocasCncSeries.Thirty_i, DeviceName = "CNC1",
|
||||
};
|
||||
var def = row.ToDefinition();
|
||||
var back = FocasDriverPage.FocasDeviceRow.FromDefinition(def);
|
||||
|
||||
back.HostAddress.ShouldBe("192.168.0.10:8193");
|
||||
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||
back.DeviceName.ShouldBe("CNC1");
|
||||
}
|
||||
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + FocasDeviceRow retired — devices are now
|
||||
// separate Device entities authored via the raw-tree DeviceModal (FocasDeviceModel, HostAddress/Series
|
||||
// into DeviceConfig). The former DeviceRow_* / ValidateDeviceRow_* tests were removed. The channel-config
|
||||
// FormModel now lives in FocasDriverForm; the multi-device Devices array it preserves is covered below.
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_preserves_unedited_fields()
|
||||
{
|
||||
var original = new FocasDeviceOptions("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i);
|
||||
var row = FocasDriverPage.FocasDeviceRow.FromDefinition(original);
|
||||
row.HostAddress = "192.168.0.20:8193";
|
||||
|
||||
var back = row.ToDefinition();
|
||||
back.HostAddress.ShouldBe("192.168.0.20:8193");
|
||||
back.DeviceName.ShouldBe("CNC1");
|
||||
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
|
||||
}
|
||||
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (FocasDriverPage.FocasTagRow +
|
||||
// FocasDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* / ValidateTagRow_* tests and the tag leg of the list-serialization test were removed.
|
||||
// The multi-device Devices editor is RETAINED, so its coverage (DeviceRow_* + ValidateDeviceRow_*
|
||||
// + the device-list serialization below, all carrying the FocasCncSeries enum) stays.
|
||||
|
||||
[Fact]
|
||||
public void ValidateDeviceRow_rejects_duplicate_host()
|
||||
{
|
||||
var rows = new List<FocasDriverPage.FocasDeviceRow> { new() { HostAddress = "192.168.0.10:8193" } };
|
||||
FocasDriverPage.FocasDeviceRow.ValidateRow(new() { HostAddress = "192.168.0.10:8193" }, rows, null)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_list_survives_options_serialize_round_trip()
|
||||
public void Device_list_survives_form_serialize_round_trip()
|
||||
{
|
||||
var devices = new List<FocasDeviceOptions>
|
||||
{
|
||||
new("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i),
|
||||
new("192.168.0.20:8193", "CNC2", FocasCncSeries.Zero_i_F),
|
||||
};
|
||||
var opts = new FocasDriverPage.FormModel().ToOptions(devices);
|
||||
var opts = new FocasDriverForm.FormModel().ToOptions(devices);
|
||||
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
|
||||
var back = JsonSerializer.Deserialize<FocasDriverOptions>(json, TestJsonOpts)!;
|
||||
back.Devices.Count.ShouldBe(2);
|
||||
|
||||
+7
-6
@@ -3,13 +3,14 @@ using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
public sealed class GalaxyDriverPageFormSerializationTests
|
||||
{
|
||||
// Matches GalaxyDriverPage._jsonOpts (camelCase, no PropertyNameCaseInsensitive).
|
||||
// Matches GalaxyDriverForm._jsonOpts (camelCase, no PropertyNameCaseInsensitive).
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -142,7 +143,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
||||
var driverOpts = JsonSerializer.Deserialize<GalaxyDriverOptions>(seededJson, _pageOpts);
|
||||
driverOpts.ShouldNotBeNull();
|
||||
|
||||
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!);
|
||||
var form = GalaxyDriverForm.GalaxyFormModel.FromRecord(driverOpts!);
|
||||
|
||||
// Assert REAL seeded values — not defaults.
|
||||
form.GatewayEndpoint.ShouldBe("http://10.100.0.48:5120");
|
||||
@@ -155,7 +156,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
||||
|
||||
/// <summary>
|
||||
/// Defence-in-depth: a config that genuinely OMITS a section (no Reconnect key at all)
|
||||
/// must not throw — <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> must
|
||||
/// must not throw — <see cref="GalaxyDriverForm.GalaxyFormModel.FromRecord"/> must
|
||||
/// null-coalesce the missing section to its default value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -175,7 +176,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
||||
driverOpts.ShouldNotBeNull();
|
||||
|
||||
// FromRecord must not throw even though Reconnect (and other sections) is null.
|
||||
var form = Should.NotThrow(() => GalaxyDriverPage.GalaxyFormModel.FromRecord(driverOpts!));
|
||||
var form = Should.NotThrow(() => GalaxyDriverForm.GalaxyFormModel.FromRecord(driverOpts!));
|
||||
|
||||
// Omitted Reconnect section falls back to GalaxyReconnectOptions() defaults.
|
||||
var defaultRc = new GalaxyReconnectOptions();
|
||||
@@ -185,7 +186,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms that <see cref="GalaxyDriverPage.GalaxyFormModel.FromRecord"/> still
|
||||
/// Confirms that <see cref="GalaxyDriverForm.GalaxyFormModel.FromRecord"/> still
|
||||
/// round-trips correctly when all nested records are populated (non-regressed path).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -216,7 +217,7 @@ public sealed class GalaxyDriverPageFormSerializationTests
|
||||
ProbeTimeoutSeconds = 20,
|
||||
};
|
||||
|
||||
var form = GalaxyDriverPage.GalaxyFormModel.FromRecord(original);
|
||||
var form = GalaxyDriverForm.GalaxyFormModel.FromRecord(original);
|
||||
|
||||
form.GatewayEndpoint.ShouldBe("https://gw.example.com:5001");
|
||||
form.GatewayApiKeySecretRef.ShouldBe("env:MY_KEY");
|
||||
|
||||
+17
-16
@@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
@@ -124,7 +125,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
ProbeTimeoutSeconds = 25,
|
||||
};
|
||||
|
||||
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original);
|
||||
var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original);
|
||||
var result = form.ToRecord(endpointUrls);
|
||||
|
||||
result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840");
|
||||
@@ -159,7 +160,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
[Fact]
|
||||
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
|
||||
{
|
||||
var row = OpcUaClientDriverPage.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 ");
|
||||
var row = OpcUaClientDriverForm.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 ");
|
||||
|
||||
row.Url.ShouldBe(" opc.tcp://plc:4840 ");
|
||||
row.ToUrl().ShouldBe("opc.tcp://plc:4840");
|
||||
@@ -168,10 +169,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
[Fact]
|
||||
public void EndpointUrlRow_ValidateRow_RejectsBlank()
|
||||
{
|
||||
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
|
||||
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " };
|
||||
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>();
|
||||
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " " };
|
||||
|
||||
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
|
||||
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
|
||||
|
||||
error.ShouldBe("URL is required.");
|
||||
}
|
||||
@@ -179,10 +180,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
[Fact]
|
||||
public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme()
|
||||
{
|
||||
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
|
||||
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" };
|
||||
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>();
|
||||
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "http://plc:4840" };
|
||||
|
||||
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
|
||||
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
|
||||
|
||||
error.ShouldBe("Endpoint URL must start with opc.tcp://");
|
||||
}
|
||||
@@ -190,15 +191,15 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
[Fact]
|
||||
public void EndpointUrlRow_ValidateRow_RejectsDuplicate()
|
||||
{
|
||||
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
|
||||
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>
|
||||
{
|
||||
new() { Url = "opc.tcp://primary:4840" },
|
||||
new() { Url = "opc.tcp://backup:4840" },
|
||||
};
|
||||
// Adding a new row (editIndex null) duplicating the first — case-insensitive, whitespace-insensitive.
|
||||
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " };
|
||||
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " };
|
||||
|
||||
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
|
||||
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, null);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("Duplicate endpoint");
|
||||
@@ -207,15 +208,15 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
[Fact]
|
||||
public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace()
|
||||
{
|
||||
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
|
||||
var all = new List<OpcUaClientDriverForm.EndpointUrlRow>
|
||||
{
|
||||
new() { Url = "opc.tcp://primary:4840" },
|
||||
new() { Url = "opc.tcp://backup:4840" },
|
||||
};
|
||||
// Editing index 0 and keeping the same URL must not flag itself as a duplicate.
|
||||
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "opc.tcp://primary:4840" };
|
||||
var row = new OpcUaClientDriverForm.EndpointUrlRow { Url = "opc.tcp://primary:4840" };
|
||||
|
||||
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, 0);
|
||||
var error = OpcUaClientDriverForm.EndpointUrlRow.ValidateRow(row, all, 0);
|
||||
|
||||
error.ShouldBeNull();
|
||||
}
|
||||
@@ -228,7 +229,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" };
|
||||
|
||||
var rows = endpointUrls
|
||||
.Select(OpcUaClientDriverPage.EndpointUrlRow.FromUrl)
|
||||
.Select(OpcUaClientDriverForm.EndpointUrlRow.FromUrl)
|
||||
.ToList();
|
||||
var roundTripped = rows.Select(r => r.ToUrl()).ToList();
|
||||
|
||||
@@ -237,7 +238,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
roundTripped[1].ShouldBe("opc.tcp://secondary:4840");
|
||||
roundTripped[2].ShouldBe("opc.tcp://tertiary:4840");
|
||||
|
||||
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions());
|
||||
var form = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions());
|
||||
var result = form.ToRecord(roundTripped);
|
||||
result.EndpointUrls.Count.ShouldBe(3);
|
||||
result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840");
|
||||
|
||||
@@ -3,13 +3,14 @@ using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
|
||||
// v3 Batch-1 migration: the S7 driver page dropped its pre-declared Tags editor — tags now live in
|
||||
// the Raw tree (/raw, Batch 2), and S7DriverOptions.Tags (the typed S7TagDefinition list) +
|
||||
// S7DriverPage.S7TagRow were retired (the driver now consumes RawTags). The former tag-editor and
|
||||
// S7DriverForm.S7TagRow were retired (the driver now consumes RawTags). The former tag-editor and
|
||||
// tag-serialization tests (S7TagRow_*, TagList_SerializeRoundTrip_PreservesTags, and the tag legs of
|
||||
// the round-trip tests) were removed. The connection/protocol-field + enum-serialization round-trip
|
||||
// coverage (CpuType, S7DataType) is retained below unchanged.
|
||||
@@ -91,7 +92,7 @@ public sealed class S7DriverPageFormSerializationTests
|
||||
ProbeTimeoutSeconds = 20,
|
||||
};
|
||||
|
||||
var form = S7DriverPage.FormModel.FromOptions(opts);
|
||||
var form = S7DriverForm.FormModel.FromOptions(opts);
|
||||
var roundTripped = form.ToOptions();
|
||||
|
||||
roundTripped.Host.ShouldBe("192.168.1.50");
|
||||
|
||||
+7
-52
@@ -2,6 +2,7 @@ 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.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||
@@ -80,8 +81,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
ProbeTimeoutSeconds = 15,
|
||||
};
|
||||
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.FormModel.FromOptions(opts);
|
||||
var form = TwinCATDriverForm.FormModel.FromOptions(opts);
|
||||
var roundTripped = form.ToOptions([]);
|
||||
|
||||
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
@@ -94,60 +94,15 @@ public sealed class TwinCATDriverPageFormSerializationTests
|
||||
roundTripped.ProbeTimeoutSeconds.ShouldBe(15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_RoundTrip_PreservesEditableFields()
|
||||
{
|
||||
var def = new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1");
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.HostAddress.ShouldBe("192.168.0.1.1.1:851");
|
||||
back.DeviceName.ShouldBe("PLC1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_CarriesThroughUneditedSourceFields()
|
||||
{
|
||||
// Edit only DeviceName; HostAddress on the source must survive the round-trip via _source.
|
||||
var def = new TwinCATDeviceOptions("10.0.0.5.1.1:851", "Original");
|
||||
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
|
||||
row.DeviceName = "Renamed";
|
||||
var back = row.ToDefinition();
|
||||
|
||||
back.HostAddress.ShouldBe("10.0.0.5.1.1:851");
|
||||
back.DeviceName.ShouldBe("Renamed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress()
|
||||
{
|
||||
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(new TwinCATDeviceOptions("192.168.0.1.1.1:851"));
|
||||
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow { HostAddress = "192.168.0.1.1.1:851" };
|
||||
|
||||
var all = new[] { existing, dup };
|
||||
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.TwinCATDeviceRow.ValidateRow(dup, all, editIndex: 1);
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain("Duplicate");
|
||||
}
|
||||
|
||||
// v3 Batch-1 migration: the pre-declared Tags editor (TwinCATDriverPage.TwinCATTagRow +
|
||||
// TwinCATDriverOptions.Tags) was retired — tags moved to the Raw tree (/raw, Batch 2). The former
|
||||
// TagRow_* tests and the tag leg of FormModel_ToOptions_* were removed. The multi-device Devices
|
||||
// editor is RETAINED, so its coverage (DeviceRow_* + the device-list serialization below) stays.
|
||||
// v3 Batch-2 (WP3): the embedded-Devices CollectionEditor + TwinCATDeviceRow retired — devices are now
|
||||
// separate Device entities authored via the raw-tree DeviceModal (TwinCATDeviceModel, HostAddress into
|
||||
// DeviceConfig). The former DeviceRow_* tests were removed. The channel-config FormModel now lives in
|
||||
// TwinCATDriverForm; the multi-device Devices array it preserves is covered below.
|
||||
|
||||
[Fact]
|
||||
public void FormModel_ToOptions_SerializesDeviceList()
|
||||
{
|
||||
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
|
||||
.TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions());
|
||||
var form = TwinCATDriverForm.FormModel.FromOptions(new TwinCATDriverOptions());
|
||||
|
||||
var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") };
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip guard for the Modbus <b>device</b> endpoint model — the gate-critical DeviceConfig
|
||||
/// surface. Endpoint keys MUST be PascalCase (Host/Port/UnitId) so the merged probe config binds via
|
||||
/// the runtime's case-insensitive deserializer, matching the seed and Wave-B service tests.
|
||||
/// </summary>
|
||||
public sealed class ModbusDeviceModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = ModbusDeviceModel.FromJson(json);
|
||||
|
||||
m.Host.ShouldBe("127.0.0.1");
|
||||
m.Port.ShouldBe(502);
|
||||
m.UnitId.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_endpoint_fields()
|
||||
{
|
||||
var m = new ModbusDeviceModel { Host = "10.100.0.35", Port = 5020, UnitId = 7 };
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = ModbusDeviceModel.FromJson(json);
|
||||
|
||||
m2.Host.ShouldBe("10.100.0.35");
|
||||
m2.Port.ShouldBe(5020);
|
||||
m2.UnitId.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_PascalCase_endpoint_keys()
|
||||
{
|
||||
var json = new ModbusDeviceModel { Host = "10.1.2.3", Port = 5020, UnitId = 1 }.ToJson();
|
||||
|
||||
// Runtime binds ModbusDriverOptions.Host/Port/UnitId from these exact PascalCase keys.
|
||||
json.ShouldContain("\"Host\":\"10.1.2.3\"");
|
||||
json.ShouldContain("\"Port\":5020");
|
||||
json.ShouldContain("\"UnitId\":1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = ModbusDeviceModel
|
||||
.FromJson("""{"Host":"10.1.2.3","Port":5020,"UnitId":1,"customKey":"keepme"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("customKey");
|
||||
json.ShouldContain("keepme");
|
||||
json.ShouldContain("\"Host\":\"10.1.2.3\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_flags_blank_host()
|
||||
{
|
||||
new ModbusDeviceModel { Host = "" }.Validate().ShouldNotBeNull();
|
||||
new ModbusDeviceModel { Host = "127.0.0.1" }.Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip + enum-serialization guard for the Modbus <b>driver</b> (channel/protocol) form model.
|
||||
/// Reproduces the historical driver-enum-serialization bug: enums MUST serialize as their name strings
|
||||
/// (JsonStringEnumConverter) with camelCase keys, or the runtime factory faults on the numeric form.
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverFormModelTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_channel_fields_and_enum()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel
|
||||
{
|
||||
Family = ModbusFamily.MELSEC,
|
||||
MelsecSubFamily = MelsecFamily.Q_L_iQR,
|
||||
TimeoutSeconds = 4,
|
||||
MaxRegistersPerRead = 100,
|
||||
WriteOnChangeOnly = true,
|
||||
KeepAliveRetryCount = 5,
|
||||
ReconnectBackoffMultiplier = 3.0,
|
||||
ProbeAddress = 40,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
|
||||
var back = ModbusDriverForm.FormModel.FromOptions(
|
||||
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
|
||||
|
||||
back.Family.ShouldBe(ModbusFamily.MELSEC);
|
||||
back.MelsecSubFamily.ShouldBe(MelsecFamily.Q_L_iQR);
|
||||
back.TimeoutSeconds.ShouldBe(4);
|
||||
back.MaxRegistersPerRead.ShouldBe(100);
|
||||
back.WriteOnChangeOnly.ShouldBeTrue();
|
||||
back.KeepAliveRetryCount.ShouldBe(5);
|
||||
back.ReconnectBackoffMultiplier.ShouldBe(3.0);
|
||||
back.ProbeAddress.ShouldBe(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialized_config_uses_camelCase_keys_and_enum_names()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel { Family = ModbusFamily.MELSEC };
|
||||
|
||||
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
|
||||
|
||||
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
|
||||
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user