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; } } }