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."); + } +}