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