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 7990462a..5fa88406 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
@@ -63,6 +63,9 @@
case DriverTypeNames.Mqtt:
break;
+ case DriverTypeNames.MTConnect:
+
+ break;
default:
No typed config form for driver type @_driverType.
break;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor
new file mode 100644
index 00000000..1bbd9674
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor
@@ -0,0 +1,406 @@
+@* Embeddable MTConnect Agent driver config form body. Hosted by DriverConfigModal (/raw). The Agent's
+ base URI is a DRIVER-level setting, not a per-device endpoint: the driver appends the standard Agent
+ paths (/probe, /current, /sample) to it and optionally narrows to one DeviceName, so unlike Modbus/S7
+ there is no host/port to split onto the device.
+
+ MTConnect is read-only — there are no write knobs to author. *@
+@using System.Text.Json
+@using System.Text.Json.Nodes
+@using System.Text.Json.Serialization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
+
+@* Agent *@
+
+
MTConnect Agent
+
+
+
+
+
+
Required. The driver appends /probe, /current and /sample to this base.
+
+
+
+
+
Narrows requests to {base}/{device}/… on a multi-device Agent.
+
+
+
+
+
+@* Polling *@
+
+
Polling
+
+
+
+
+
+
Default 5000. Must be > 0.
+
+
+
+
+
Default 1000. Must be > 0 — a 0 busy-loops the stream.
+
+
+
+
+
Default 1000 observations per chunk. Must be > 0.
+
+
+
+
+
Default 10000. Must be > 0 — a 0 makes a quiet stream indistinguishable from a dead one.
+
+
+
+
+
+@* Connectivity probe *@
+
+
Connectivity probe
+
+
+
+
+
+
+
+
Periodic /probe driving the Running/Stopped host status.
+
+
+
+
+
Default 5000. Must be > 0 while probing is on.
+
+
+
+
+
Default 2000. Must be > 0 while probing is on.
+
+
+
+
+
+@* Reconnect *@
+
+
Reconnect backoff
+
+
+
+
+
+
Default 0 (retry immediately).
+
+
+
+
+
Default 30000.
+
+
+
+
+
Default 2.0 (doubles each retry).
+
+
+
+
+
+@if (_form.Validate() is { } error)
+{
+
@error
+}
+
+
+
+@code {
+ /// The driver's DriverConfig JSON (supports @bind-DriverConfigJson).
+ [Parameter] public string DriverConfigJson { get; set; } = "{}";
+ /// Two-way config callback.
+ [Parameter] public EventCallback DriverConfigJsonChanged { get; set; }
+ /// The driver's resilience-policy JSON, or null.
+ [Parameter] public string? ResilienceConfig { get; set; }
+ /// Two-way resilience callback.
+ [Parameter] public EventCallback ResilienceConfigChanged { get; set; }
+
+ // Case-insensitive read so a PascalCase seeded/hand-authored config loads its real values instead of
+ // silently showing defaults; camelCase write to match the driver's own DTO spelling. The string-enum
+ // converter is mandatory across the whole *DriverForm fleet (DriverPageJsonConverterTests) — the
+ // MTConnect config carries no enum today, and the guard is precisely what stops the first one added
+ // later from serialising as a number the string-typed factory cannot parse.
+ private static readonly JsonSerializerOptions _jsonOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ WriteIndented = false,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ private MTConnectFormModel _form = new();
+ private string? _lastParsed;
+
+ protected override void OnParametersSet()
+ {
+ if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
+ {
+ _form = MTConnectFormModel.FromJson(DriverConfigJson);
+ _lastParsed = DriverConfigJson;
+ }
+ }
+
+ /// Serialises the current form state over the config document it was loaded from.
+ /// The driver's DriverConfig JSON.
+ public string GetConfigJson() => BuildConfigJson(_form, DriverConfigJson);
+
+ ///
+ /// Projects onto , returning the driver's
+ /// DriverConfig JSON. Pure and static so the whole decision surface is unit-testable —
+ /// this project has no bUnit, so anything left in markup is verified only by the live gate.
+ ///
+ ///
+ ///
+ /// Unknown TOP-LEVEL keys in are preserved (notably the
+ /// driver's hand-authored tags[] surface, which this form does not expose and must not
+ /// silently delete). The probe and reconnect sub-objects are replaced wholesale
+ /// — the form owns every field in both.
+ ///
+ ///
+ /// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather
+ /// than written: ParseOptions then substitutes the driver's own positive default
+ /// instead of the driver faulting at Initialize on RequirePositive (arch-review
+ /// 01/S-6). Removing rather than merely skipping matters — a stale positive value already in
+ /// the document would otherwise survive and disagree with what the form shows.
+ ///
+ ///
+ /// The form state to serialise.
+ /// The config document being edited, or null when creating.
+ /// The driver's DriverConfig JSON.
+ public static string BuildConfigJson(MTConnectFormModel form, string? existingJson)
+ {
+ ArgumentNullException.ThrowIfNull(form);
+
+ var bag = ParseObject(existingJson) ?? new JsonObject();
+ var emitted = JsonNode.Parse(JsonSerializer.Serialize(form.ToDto(), _jsonOpts))!.AsObject();
+
+ foreach (var key in emitted.Select(p => p.Key).ToList())
+ {
+ var value = emitted[key];
+ emitted.Remove(key); // detach before re-parenting into the bag
+
+ if (value is null)
+ {
+ bag.Remove(key);
+ }
+ else
+ {
+ // A nulled member of a sub-object must be an ABSENT key, not "key": null. The driver's
+ // nullable DTO binds both to "use the default", but an explicit null in a stored config
+ // reads as a deliberately-cleared setting to anyone inspecting it.
+ if (value is JsonObject nested) { StripNulls(nested); }
+ bag[key] = value;
+ }
+ }
+
+ return bag.ToJsonString();
+ }
+
+ private static void StripNulls(JsonObject o)
+ {
+ foreach (var key in o.Where(p => p.Value is null).Select(p => p.Key).ToList())
+ {
+ o.Remove(key);
+ }
+ }
+
+ 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 JsonObject? ParseObject(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) { return null; }
+ try { return JsonNode.Parse(json) as JsonObject; }
+ catch (JsonException) { return null; }
+ }
+
+ ///
+ /// Mutable mirror of the driver's DriverConfig document. Deliberately NOT
+ /// MTConnectDriverOptions: that type models the parsed options (TimeSpan probe knobs,
+ /// materialised tag lists), and serialising it would emit "probe":{"interval":"00:00:05"}
+ /// — which the driver's integer intervalMs DTO cannot bind.
+ ///
+ public sealed class MTConnectFormModel
+ {
+ /// The Agent's base URI. Required by the driver.
+ public string AgentUri { get; set; } = "";
+ /// Optional single-device scope; blank = agent-wide.
+ public string? DeviceName { get; set; }
+ /// Per-call HTTP deadline in ms. Must be > 0.
+ public int RequestTimeoutMs { get; set; } = 5_000;
+ /// The /sampleinterval query parameter in ms. Must be > 0.
+ public int SampleIntervalMs { get; set; } = 1_000;
+ /// The /samplecount query parameter. Must be > 0.
+ public int SampleCount { get; set; } = 1_000;
+ /// The /sampleheartbeat query parameter in ms. Must be > 0.
+ public int HeartbeatMs { get; set; } = 10_000;
+ /// Whether the background connectivity probe runs.
+ public bool ProbeEnabled { get; set; } = true;
+ /// Probe cadence in ms. Must be > 0 while .
+ public int ProbeIntervalMs { get; set; } = 5_000;
+ /// Probe request deadline in ms. Must be > 0 while .
+ public int ProbeTimeoutMs { get; set; } = 2_000;
+ /// Delay before the first reconnect attempt in ms; 0 = immediate, and legal.
+ public int ReconnectMinBackoffMs { get; set; }
+ /// Upper bound on the geometric reconnect backoff, in ms.
+ public int ReconnectMaxBackoffMs { get; set; } = 30_000;
+ /// Multiplier applied to the reconnect backoff each retry.
+ public double ReconnectBackoffMultiplier { get; set; } = 2.0;
+
+ /// Loads a form model from a DriverConfig JSON document, falling back to the driver's
+ /// own defaults for anything absent or unusable.
+ /// The DriverConfig JSON, or null/blank/malformed for a fresh form.
+ /// The populated form model.
+ public static MTConnectFormModel FromJson(string? json)
+ {
+ var dto = TryDeserialize(json);
+ var defaults = new MTConnectFormModel();
+ if (dto is null) { return defaults; }
+
+ return new MTConnectFormModel
+ {
+ AgentUri = dto.AgentUri ?? "",
+ DeviceName = dto.DeviceName,
+ RequestTimeoutMs = dto.RequestTimeoutMs ?? defaults.RequestTimeoutMs,
+ SampleIntervalMs = dto.SampleIntervalMs ?? defaults.SampleIntervalMs,
+ SampleCount = dto.SampleCount ?? defaults.SampleCount,
+ HeartbeatMs = dto.HeartbeatMs ?? defaults.HeartbeatMs,
+ ProbeEnabled = dto.Probe?.Enabled ?? defaults.ProbeEnabled,
+ ProbeIntervalMs = dto.Probe?.IntervalMs ?? defaults.ProbeIntervalMs,
+ ProbeTimeoutMs = dto.Probe?.TimeoutMs ?? defaults.ProbeTimeoutMs,
+ ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs,
+ ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs,
+ ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier,
+ };
+ }
+
+ /// Projects the form onto the driver's wire DTO, nulling any value the driver would
+ /// reject so the key is omitted and the driver's own default applies.
+ /// The wire DTO.
+ public MTConnectConfigDto ToDto() => new()
+ {
+ AgentUri = Trimmed(AgentUri),
+ DeviceName = Trimmed(DeviceName),
+ RequestTimeoutMs = Positive(RequestTimeoutMs),
+ SampleIntervalMs = Positive(SampleIntervalMs),
+ SampleCount = Positive(SampleCount),
+ HeartbeatMs = Positive(HeartbeatMs),
+ Probe = new MTConnectProbeConfigDto
+ {
+ Enabled = ProbeEnabled,
+ IntervalMs = Positive(ProbeIntervalMs),
+ TimeoutMs = Positive(ProbeTimeoutMs),
+ },
+ Reconnect = new MTConnectReconnectConfigDto
+ {
+ MinBackoffMs = ReconnectMinBackoffMs >= 0 ? ReconnectMinBackoffMs : null,
+ MaxBackoffMs = Positive(ReconnectMaxBackoffMs),
+ BackoffMultiplier = ReconnectBackoffMultiplier > 0 ? ReconnectBackoffMultiplier : null,
+ },
+ };
+
+ /// Returns an operator-facing message describing why this config would not start, or
+ /// null when it is usable. Advisory: already prevents an unusable value
+ /// from reaching the driver, so this exists to explain the snap-back rather than to gate save.
+ /// The error message, or null when valid.
+ public string? Validate()
+ {
+ if (string.IsNullOrWhiteSpace(AgentUri))
+ {
+ return "An Agent base URI is required — the driver cannot start without it.";
+ }
+
+ var bad = new List();
+ if (RequestTimeoutMs <= 0) { bad.Add("Request timeout"); }
+ if (SampleIntervalMs <= 0) { bad.Add("Sample interval"); }
+ if (SampleCount <= 0) { bad.Add("Sample count"); }
+ if (HeartbeatMs <= 0) { bad.Add("Heartbeat"); }
+ if (ProbeEnabled && ProbeIntervalMs <= 0) { bad.Add("Probe interval"); }
+ if (ProbeEnabled && ProbeTimeoutMs <= 0) { bad.Add("Probe timeout"); }
+ if (ReconnectMaxBackoffMs <= 0) { bad.Add("Max backoff"); }
+
+ return bad.Count == 0
+ ? null
+ : $"Must be greater than zero: {string.Join(", ", bad)}. A non-positive value is dropped on save and the driver's default applies.";
+ }
+
+ private static int? Positive(int value) => value > 0 ? value : null;
+
+ private static string? Trimmed(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private static MTConnectConfigDto? TryDeserialize(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) { return null; }
+ try { return JsonSerializer.Deserialize(json, _jsonOpts); }
+ catch (JsonException) { return null; }
+ }
+ }
+
+ /// Mirror of the driver's private MTConnectDriverConfigDto — the exact shape
+ /// MTConnectDriver.ParseOptions binds. Every member is nullable so an omitted key means "use
+ /// the driver's default" rather than "reset to zero".
+ public sealed class MTConnectConfigDto
+ {
+ /// The Agent's base URI.
+ public string? AgentUri { get; init; }
+ /// Optional single-device scope.
+ public string? DeviceName { get; init; }
+ /// Per-call HTTP deadline in ms.
+ public int? RequestTimeoutMs { get; init; }
+ /// The /sampleinterval query parameter in ms.
+ public int? SampleIntervalMs { get; init; }
+ /// The /samplecount query parameter.
+ public int? SampleCount { get; init; }
+ /// The /sampleheartbeat query parameter in ms.
+ public int? HeartbeatMs { get; init; }
+ /// Background connectivity-probe knobs.
+ public MTConnectProbeConfigDto? Probe { get; init; }
+ /// Reconnect backoff knobs.
+ public MTConnectReconnectConfigDto? Reconnect { get; init; }
+ }
+
+ /// Wire mirror of the driver's probe config block.
+ public sealed class MTConnectProbeConfigDto
+ {
+ /// Whether probing runs.
+ public bool? Enabled { get; init; }
+ /// Probe cadence in ms.
+ public int? IntervalMs { get; init; }
+ /// Probe request deadline in ms.
+ public int? TimeoutMs { get; init; }
+ }
+
+ /// Wire mirror of the driver's reconnect config block.
+ public sealed class MTConnectReconnectConfigDto
+ {
+ /// Delay before the first reconnect attempt, in ms.
+ public int? MinBackoffMs { get; init; }
+ /// Upper bound on the geometric backoff, in ms.
+ public int? MaxBackoffMs { get; init; }
+ /// Multiplier applied each retry.
+ public double? BackoffMultiplier { get; init; }
+ }
+}
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 3ddc867f..510a97b1 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
@@ -70,7 +70,8 @@
("MQTT", DriverTypeNames.Mqtt),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
- ("Calculation", "Calculation"),
+ ("Calculation", DriverTypeNames.Calculation),
+ ("MTConnect", DriverTypeNames.MTConnect),
];
private string _type = DriverTypeNames.Modbus;
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs
new file mode 100644
index 00000000..b291a90b
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs
@@ -0,0 +1,322 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
+
+///
+/// Unit tests for the plain-C# core of — the pure form model and
+/// its DriverConfig JSON projection. There is no bUnit in this project, so every decision the
+/// form makes lives here (in code a test can reach) rather than in razor markup.
+///
+///
+/// The JSON shape asserted below is the one MTConnectDriver.ParseOptions binds
+/// (agentUri / deviceName / requestTimeoutMs / sampleIntervalMs /
+/// sampleCount / heartbeatMs / probe / reconnect). It is deliberately
+/// NOT MTConnectDriverOptions: that type carries TimeSpan probe knobs, which serialise
+/// as "00:00:05" and would not bind to the driver's intervalMs integers.
+///
+public sealed class MTConnectDriverFormModelTests
+{
+ // Mirrors MTConnectDriverForm._jsonOpts for the read side of the assertions.
+ private static readonly JsonSerializerOptions _opts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ WriteIndented = false,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ private static JsonObject Emit(MTConnectDriverForm.MTConnectFormModel form, string? existingJson = null)
+ => JsonNode.Parse(MTConnectDriverForm.BuildConfigJson(form, existingJson))!.AsObject();
+
+ // ---- default shape -------------------------------------------------------------------------
+
+ [Fact]
+ public void Default_form_emits_a_config_the_driver_can_parse()
+ {
+ var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" });
+
+ o["agentUri"]!.GetValue().ShouldBe("http://agent:5000");
+ o["requestTimeoutMs"]!.GetValue().ShouldBe(5000);
+ o["sampleIntervalMs"]!.GetValue().ShouldBe(1000);
+ o["sampleCount"]!.GetValue().ShouldBe(1000);
+ o["heartbeatMs"]!.GetValue().ShouldBe(10000);
+ o["probe"]!["enabled"]!.GetValue().ShouldBeTrue();
+ o["probe"]!["intervalMs"]!.GetValue().ShouldBe(5000);
+ o["probe"]!["timeoutMs"]!.GetValue().ShouldBe(2000);
+ o["reconnect"]!["maxBackoffMs"]!.GetValue().ShouldBe(30000);
+ o["reconnect"]!["backoffMultiplier"]!.GetValue().ShouldBe(2.0);
+ }
+
+ [Fact]
+ public void Blank_agent_uri_is_omitted_rather_than_written_as_an_empty_string()
+ {
+ // An empty "agentUri" and an absent one both fail ParseOptions, but omitting it keeps the
+ // driver's own "missing required AgentUri" message accurate.
+ var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = " " });
+ o.ContainsKey("agentUri").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Agent_uri_and_device_name_are_trimmed_and_blank_device_name_is_omitted()
+ {
+ var o = Emit(new MTConnectDriverForm.MTConnectFormModel
+ {
+ AgentUri = " http://agent:5000 ",
+ DeviceName = " ",
+ });
+
+ o["agentUri"]!.GetValue().ShouldBe("http://agent:5000");
+ o.ContainsKey("deviceName").ShouldBeFalse();
+ }
+
+ // ---- arch-review 01/S-6: a zero must never reach the driver -------------------------------
+
+ [Theory]
+ [InlineData("requestTimeoutMs")]
+ [InlineData("sampleIntervalMs")]
+ [InlineData("sampleCount")]
+ [InlineData("heartbeatMs")]
+ public void Non_positive_timing_knobs_are_omitted_so_the_driver_applies_its_own_default(string key)
+ {
+ var form = new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" };
+ switch (key)
+ {
+ case "requestTimeoutMs": form.RequestTimeoutMs = 0; break;
+ case "sampleIntervalMs": form.SampleIntervalMs = -1; break;
+ case "sampleCount": form.SampleCount = 0; break;
+ case "heartbeatMs": form.HeartbeatMs = 0; break;
+ }
+
+ // MTConnectDriver.StartCore calls RequirePositive on all four and faults the driver at
+ // Initialize; omitting the key means ParseOptions substitutes the positive default instead.
+ Emit(form).ContainsKey(key).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Non_positive_probe_knobs_are_omitted_so_the_driver_applies_its_own_default()
+ {
+ var o = Emit(new MTConnectDriverForm.MTConnectFormModel
+ {
+ AgentUri = "http://agent:5000",
+ ProbeIntervalMs = 0,
+ ProbeTimeoutMs = -5,
+ });
+
+ var probe = o["probe"]!.AsObject();
+ probe["enabled"]!.GetValue().ShouldBeTrue();
+ probe.ContainsKey("intervalMs").ShouldBeFalse();
+ probe.ContainsKey("timeoutMs").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Zero_reconnect_min_backoff_is_preserved_because_the_driver_allows_it()
+ {
+ // MinBackoffMs = 0 means "retry immediately" and is the driver's own default — it must NOT be
+ // swept up by the positive-only rule that guards the four RequirePositive knobs.
+ var o = Emit(new MTConnectDriverForm.MTConnectFormModel
+ {
+ AgentUri = "http://agent:5000",
+ ReconnectMinBackoffMs = 0,
+ });
+
+ o["reconnect"]!["minBackoffMs"]!.GetValue().ShouldBe(0);
+ }
+
+ // ---- Validate ------------------------------------------------------------------------------
+
+ [Fact]
+ public void Validate_rejects_a_blank_agent_uri()
+ => new MTConnectDriverForm.MTConnectFormModel { AgentUri = "" }
+ .Validate().ShouldNotBeNullOrEmpty();
+
+ [Fact]
+ public void Validate_rejects_a_non_positive_timing_knob()
+ => new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", SampleCount = 0 }
+ .Validate().ShouldNotBeNullOrEmpty();
+
+ [Fact]
+ public void Validate_ignores_probe_knobs_when_probing_is_off()
+ => new MTConnectDriverForm.MTConnectFormModel
+ {
+ AgentUri = "http://agent:5000",
+ ProbeEnabled = false,
+ ProbeIntervalMs = 0,
+ ProbeTimeoutMs = 0,
+ }.Validate().ShouldBeNull();
+
+ [Fact]
+ public void Validate_accepts_a_fully_populated_form()
+ => new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" }
+ .Validate().ShouldBeNull();
+
+ // ---- round trip ----------------------------------------------------------------------------
+
+ [Fact]
+ public void Round_trip_preserves_every_authored_value()
+ {
+ var original = new MTConnectDriverForm.MTConnectFormModel
+ {
+ AgentUri = "http://cell-a:5000",
+ DeviceName = "VMC-1",
+ RequestTimeoutMs = 7000,
+ SampleIntervalMs = 250,
+ SampleCount = 500,
+ HeartbeatMs = 15000,
+ ProbeEnabled = false,
+ ProbeIntervalMs = 9000,
+ ProbeTimeoutMs = 3000,
+ ReconnectMinBackoffMs = 100,
+ ReconnectMaxBackoffMs = 60000,
+ ReconnectBackoffMultiplier = 1.5,
+ };
+
+ var json = MTConnectDriverForm.BuildConfigJson(original, null);
+ var back = MTConnectDriverForm.MTConnectFormModel.FromJson(json);
+
+ back.AgentUri.ShouldBe("http://cell-a:5000");
+ back.DeviceName.ShouldBe("VMC-1");
+ back.RequestTimeoutMs.ShouldBe(7000);
+ back.SampleIntervalMs.ShouldBe(250);
+ back.SampleCount.ShouldBe(500);
+ back.HeartbeatMs.ShouldBe(15000);
+ back.ProbeEnabled.ShouldBeFalse();
+ back.ProbeIntervalMs.ShouldBe(9000);
+ back.ProbeTimeoutMs.ShouldBe(3000);
+ back.ReconnectMinBackoffMs.ShouldBe(100);
+ back.ReconnectMaxBackoffMs.ShouldBe(60000);
+ back.ReconnectBackoffMultiplier.ShouldBe(1.5);
+ }
+
+ [Fact]
+ public void FromJson_reads_a_pascal_case_seeded_config()
+ {
+ // Seed SQL / hand-authored configs are PascalCase; the driver reads them case-insensitively,
+ // so the form must too or it silently shows defaults over a real config.
+ const string seeded = """
+ {
+ "AgentUri": "http://10.100.0.35:5000",
+ "DeviceName": "Mill-01",
+ "RequestTimeoutMs": 8000,
+ "Probe": { "Enabled": false, "IntervalMs": 7000, "TimeoutMs": 4000 },
+ "Reconnect": { "MinBackoffMs": 250, "MaxBackoffMs": 45000, "BackoffMultiplier": 3.0 }
+ }
+ """;
+
+ var form = MTConnectDriverForm.MTConnectFormModel.FromJson(seeded);
+
+ form.AgentUri.ShouldBe("http://10.100.0.35:5000");
+ form.DeviceName.ShouldBe("Mill-01");
+ form.RequestTimeoutMs.ShouldBe(8000);
+ form.ProbeEnabled.ShouldBeFalse();
+ form.ProbeIntervalMs.ShouldBe(7000);
+ form.ProbeTimeoutMs.ShouldBe(4000);
+ form.ReconnectMinBackoffMs.ShouldBe(250);
+ form.ReconnectMaxBackoffMs.ShouldBe(45000);
+ form.ReconnectBackoffMultiplier.ShouldBe(3.0);
+ }
+
+ [Fact]
+ public void FromJson_falls_back_to_defaults_for_an_unusable_document()
+ {
+ foreach (var bad in new[] { null, "", " ", "{", "\"scalar\"" })
+ {
+ var form = MTConnectDriverForm.MTConnectFormModel.FromJson(bad);
+ form.RequestTimeoutMs.ShouldBe(5000);
+ form.SampleCount.ShouldBe(1000);
+ }
+ }
+
+ [Fact]
+ public void Unknown_top_level_keys_survive_a_load_save()
+ {
+ // The driver also accepts a hand-authored tags[] surface; opening the form must not delete it.
+ const string existing = """{"agentUri":"http://old:5000","tags":[{"fullName":"x1"}]}""";
+
+ var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
+ form.AgentUri = "http://new:5000";
+
+ var o = Emit(form, existing);
+ o["agentUri"]!.GetValue().ShouldBe("http://new:5000");
+ o["tags"]!.AsArray().Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public void A_previously_written_key_is_removed_when_its_value_becomes_non_positive()
+ {
+ const string existing = """{"agentUri":"http://a:5000","requestTimeoutMs":9000}""";
+
+ var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
+ form.RequestTimeoutMs = 0;
+
+ // Not merely "not written" — the stale 9000 must not survive either, or the UI would show 0
+ // while the stored config still said 9000.
+ Emit(form, existing).ContainsKey("requestTimeoutMs").ShouldBeFalse();
+ }
+
+ // ---- the shape the driver actually binds ---------------------------------------------------
+
+ [Fact]
+ public void Emitted_config_binds_to_the_drivers_wire_dto_shape()
+ {
+ var json = MTConnectDriverForm.BuildConfigJson(
+ new MTConnectDriverForm.MTConnectFormModel
+ {
+ AgentUri = "http://agent:5000",
+ DeviceName = "VMC-1",
+ RequestTimeoutMs = 7000,
+ },
+ null);
+
+ // Mirror of MTConnectDriver.MTConnectDriverConfigDto (private there), asserting the emitted
+ // document binds field-for-field — a rename on either side breaks this.
+ var dto = JsonSerializer.Deserialize(json, _opts);
+
+ dto.ShouldNotBeNull();
+ dto!.AgentUri.ShouldBe("http://agent:5000");
+ dto.DeviceName.ShouldBe("VMC-1");
+ dto.RequestTimeoutMs.ShouldBe(7000);
+ dto.SampleIntervalMs.ShouldBe(1000);
+ dto.SampleCount.ShouldBe(1000);
+ dto.HeartbeatMs.ShouldBe(10000);
+ dto.Probe.ShouldNotBeNull();
+ dto.Probe!.Enabled.ShouldBe(true);
+ dto.Probe.IntervalMs.ShouldBe(5000);
+ dto.Probe.TimeoutMs.ShouldBe(2000);
+ dto.Reconnect.ShouldNotBeNull();
+ dto.Reconnect!.MinBackoffMs.ShouldBe(0);
+ dto.Reconnect.MaxBackoffMs.ShouldBe(30000);
+ dto.Reconnect.BackoffMultiplier.ShouldBe(2.0);
+ }
+
+ private sealed class WireDto
+ {
+ public string? AgentUri { get; init; }
+ public string? DeviceName { get; init; }
+ public int? RequestTimeoutMs { get; init; }
+ public int? SampleIntervalMs { get; init; }
+ public int? SampleCount { get; init; }
+ public int? HeartbeatMs { get; init; }
+ public WireProbe? Probe { get; init; }
+ public WireReconnect? Reconnect { get; init; }
+ }
+
+ private sealed class WireProbe
+ {
+ public bool? Enabled { get; init; }
+ public int? IntervalMs { get; init; }
+ public int? TimeoutMs { get; init; }
+ }
+
+ private sealed class WireReconnect
+ {
+ public int? MinBackoffMs { get; init; }
+ public int? MaxBackoffMs { get; init; }
+ public double? BackoffMultiplier { get; init; }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawDriverTypeDialogCoverageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawDriverTypeDialogCoverageTests.cs
new file mode 100644
index 00000000..9610d22d
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawDriverTypeDialogCoverageTests.cs
@@ -0,0 +1,59 @@
+using System.Reflection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Coverage guard for the /raw "New driver" dialog. is the
+/// ONLY surface that creates a DriverInstance, and its offered types are a hand-authored array
+/// — so a driver whose factory is registered but whose entry was never added here is
+/// un-creatable in the AdminUI with nothing failing anywhere (the MTConnect gap Task 16 found).
+/// This test resolves through , so a newly registered driver type
+/// fails here until the dialog offers it.
+///
+///
+/// There is no bUnit in this project, so the dialog's markup cannot be rendered. The offered set is
+/// read by reflection off the private static Types table the markup enumerates — the same
+/// substitute-for-a-component-test approach DriverPageJsonConverterTests uses.
+///
+public sealed class RawDriverTypeDialogCoverageTests
+{
+ private static IReadOnlyList OfferedDriverTypes()
+ {
+ var field = typeof(RawDriverTypeDialog)
+ .GetField("Types", BindingFlags.NonPublic | BindingFlags.Static);
+ field.ShouldNotBeNull("RawDriverTypeDialog must expose its offered driver types as a static 'Types' table.");
+
+ var table = (ValueTuple[])field!.GetValue(null)!;
+ return [.. table.Select(t => t.Item2)];
+ }
+
+ [Theory]
+ [MemberData(nameof(AllDriverTypes))]
+ public void New_driver_dialog_offers_every_registered_driver_type(string driverType)
+ => OfferedDriverTypes().ShouldContain(
+ driverType,
+ $"RawDriverTypeDialog must offer DriverTypeNames.{driverType} — otherwise a driver of that " +
+ "type can never be created in /raw, which is the only creation surface.");
+
+ /// xUnit theory source over every registered driver-type constant.
+ public static TheoryData AllDriverTypes()
+ {
+ var data = new TheoryData();
+ foreach (var t in DriverTypeNames.All)
+ data.Add(t);
+ return data;
+ }
+
+ [Fact]
+ public void Offered_driver_types_are_canonical_constants_and_unique()
+ {
+ var offered = OfferedDriverTypes();
+ offered.Distinct(StringComparer.Ordinal).Count().ShouldBe(offered.Count, "duplicate driver-type entries");
+ foreach (var t in offered)
+ DriverTypeNames.All.ShouldContain(t, $"'{t}' is not a canonical DriverTypeNames constant.");
+ }
+}