9227d03ca8
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.
323 lines
13 KiB
C#
323 lines
13 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the plain-C# core of <see cref="MTConnectDriverForm"/> — the pure form model and
|
|
/// its <c>DriverConfig</c> 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The JSON shape asserted below is the one <c>MTConnectDriver.ParseOptions</c> binds
|
|
/// (<c>agentUri</c> / <c>deviceName</c> / <c>requestTimeoutMs</c> / <c>sampleIntervalMs</c> /
|
|
/// <c>sampleCount</c> / <c>heartbeatMs</c> / <c>probe</c> / <c>reconnect</c>). It is deliberately
|
|
/// NOT <c>MTConnectDriverOptions</c>: that type carries <c>TimeSpan</c> probe knobs, which serialise
|
|
/// as <c>"00:00:05"</c> and would not bind to the driver's <c>intervalMs</c> integers.
|
|
/// </remarks>
|
|
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<string>().ShouldBe("http://agent:5000");
|
|
o["requestTimeoutMs"]!.GetValue<int>().ShouldBe(5000);
|
|
o["sampleIntervalMs"]!.GetValue<int>().ShouldBe(1000);
|
|
o["sampleCount"]!.GetValue<int>().ShouldBe(1000);
|
|
o["heartbeatMs"]!.GetValue<int>().ShouldBe(10000);
|
|
o["probe"]!["enabled"]!.GetValue<bool>().ShouldBeTrue();
|
|
o["probe"]!["intervalMs"]!.GetValue<int>().ShouldBe(5000);
|
|
o["probe"]!["timeoutMs"]!.GetValue<int>().ShouldBe(2000);
|
|
o["reconnect"]!["maxBackoffMs"]!.GetValue<int>().ShouldBe(30000);
|
|
o["reconnect"]!["backoffMultiplier"]!.GetValue<double>().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<string>().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<bool>().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<int>().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<string>().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<WireDto>(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; }
|
|
}
|
|
}
|