From 9227d03ca88a551217fe022b753ae923ef565672 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 18:11:51 -0400 Subject: [PATCH] feat(mtconnect): make an MTConnect driver creatable + configurable in /raw (Task 18 Part B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An MTConnect driver instance could not be authored at all: RawDriverTypeDialog (the only DriverInstance creation surface) offered a hardcoded 9-entry list without it, and DriverConfigModal's default: branch renders a warning with no raw-JSON fallback — so even a hand-inserted row could not be configured and DriverConfig stayed "{}", on which ParseOptions throws "missing required AgentUri". Adds the typed MTConnectDriverForm (the consistent sibling pattern) covering agentUri / deviceName / the four polling knobs / probe / reconnect, wires the dispatch case, and offers the type in the dialog. - All decisions live in the static MTConnectDriverForm.BuildConfigJson + MTConnectFormModel (plain C#), because this project has no bUnit. - The wire shape mirrors the driver's private MTConnectDriverConfigDto, NOT MTConnectDriverOptions: the latter's TimeSpan probe knobs would serialise as "00:00:05" and never bind to the driver's integer intervalMs. - arch-review 01/S-6: a non-positive timing knob is REMOVED from the document rather than written, so ParseOptions substitutes the driver's own positive default instead of the driver faulting at Initialize on RequirePositive. Removal (not just skipping) also stops a stale positive value surviving and disagreeing with what the form shows. - Unknown top-level keys survive a load/save, so the driver's hand-authored tags[] surface is not silently deleted by opening the form. - _jsonOpts carries JsonStringEnumConverter, satisfying the DriverPageJsonConverterTests fleet guard properly rather than by exemption. New RawDriverTypeDialogCoverageTests resolves the offered set through DriverTypeNames.All, so the next registered driver fails here until the dialog offers it. --- .../Shared/Drivers/DriverConfigModal.razor | 3 + .../Drivers/Forms/MTConnectDriverForm.razor | 406 ++++++++++++++++++ .../Shared/Raw/RawDriverTypeDialog.razor | 3 +- .../MTConnectDriverFormModelTests.cs | 322 ++++++++++++++ .../Uns/RawDriverTypeDialogCoverageTests.cs | 59 +++ 5 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawDriverTypeDialogCoverageTests.cs 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 /sample interval query parameter in ms. Must be > 0. + public int SampleIntervalMs { get; set; } = 1_000; + /// The /sample count query parameter. Must be > 0. + public int SampleCount { get; set; } = 1_000; + /// The /sample heartbeat 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 /sample interval query parameter in ms. + public int? SampleIntervalMs { get; init; } + /// The /sample count query parameter. + public int? SampleCount { get; init; } + /// The /sample heartbeat 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."); + } +}