feat(mtconnect): make an MTConnect driver creatable + configurable in /raw (Task 18 Part B)

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.
This commit is contained in:
Joseph Doherty
2026-07-24 18:11:51 -04:00
parent 115a43dd47
commit 9227d03ca8
5 changed files with 792 additions and 1 deletions
@@ -63,6 +63,9 @@
case DriverTypeNames.Mqtt: case DriverTypeNames.Mqtt:
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" /> <MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break; break;
case DriverTypeNames.MTConnect:
<MTConnectDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default: default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div> <div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break; break;
@@ -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 *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">MTConnect Agent</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Agent base URI</label>
<InputText @bind-Value="_form.AgentUri" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="http://agent.internal:5000" />
<div class="form-text">Required. The driver appends <span class="mono">/probe</span>, <span class="mono">/current</span> and <span class="mono">/sample</span> to this base.</div>
</div>
<div class="col-md-6">
<label class="form-label">Device name (blank = all devices)</label>
<InputText @bind-Value="_form.DeviceName" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="VMC-1" />
<div class="form-text">Narrows requests to <span class="mono">{base}/{device}/…</span> on a multi-device Agent.</div>
</div>
</div>
</div>
</section>
@* Polling *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Polling</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Request timeout (ms)</label>
<InputNumber @bind-Value="_form.RequestTimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 5000. Must be &gt; 0.</div>
</div>
<div class="col-md-3">
<label class="form-label">Sample interval (ms)</label>
<InputNumber @bind-Value="_form.SampleIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 1000. Must be &gt; 0 — a 0 busy-loops the stream.</div>
</div>
<div class="col-md-3">
<label class="form-label">Sample count</label>
<InputNumber @bind-Value="_form.SampleCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 1000 observations per chunk. Must be &gt; 0.</div>
</div>
<div class="col-md-3">
<label class="form-label">Heartbeat (ms)</label>
<InputNumber @bind-Value="_form.HeartbeatMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 10000. Must be &gt; 0 — a 0 makes a quiet stream indistinguishable from a dead one.</div>
</div>
</div>
</div>
</section>
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="mtcProbeEnabled" />
<label class="form-check-label" for="mtcProbeEnabled">Probe enabled</label>
</div>
<div class="form-text mt-0">Periodic <span class="mono">/probe</span> driving the Running/Stopped host status.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe interval (ms)</label>
<InputNumber @bind-Value="_form.ProbeIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" disabled="@(!_form.ProbeEnabled)" />
<div class="form-text">Default 5000. Must be &gt; 0 while probing is on.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe timeout (ms)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" disabled="@(!_form.ProbeEnabled)" />
<div class="form-text">Default 2000. Must be &gt; 0 while probing is on.</div>
</div>
</div>
</div>
</section>
@* Reconnect *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Reconnect backoff</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Min backoff (ms)</label>
<InputNumber @bind-Value="_form.ReconnectMinBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="0" />
<div class="form-text">Default 0 (retry immediately).</div>
</div>
<div class="col-md-3">
<label class="form-label">Max backoff (ms)</label>
<InputNumber @bind-Value="_form.ReconnectMaxBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" min="1" />
<div class="form-text">Default 30000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Backoff multiplier</label>
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 2.0 (doubles each retry).</div>
</div>
</div>
</div>
</section>
@if (_form.Validate() is { } error)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@error</div>
}
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver's DriverConfig JSON (supports <c>@bind-DriverConfigJson</c>).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Two-way config callback.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The driver's resilience-policy JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Two-way resilience callback.</summary>
[Parameter] public EventCallback<string?> 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;
}
}
/// <summary>Serialises the current form state over the config document it was loaded from.</summary>
/// <returns>The driver's DriverConfig JSON.</returns>
public string GetConfigJson() => BuildConfigJson(_form, DriverConfigJson);
/// <summary>
/// Projects <paramref name="form"/> onto <paramref name="existingJson"/>, returning the driver's
/// <c>DriverConfig</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// Unknown TOP-LEVEL keys in <paramref name="existingJson"/> are preserved (notably the
/// driver's hand-authored <c>tags[]</c> surface, which this form does not expose and must not
/// silently delete). The <c>probe</c> and <c>reconnect</c> sub-objects are replaced wholesale
/// — the form owns every field in both.
/// </para>
/// <para>
/// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather
/// than written: <c>ParseOptions</c> then substitutes the driver's own positive default
/// instead of the driver faulting at Initialize on <c>RequirePositive</c> (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.
/// </para>
/// </remarks>
/// <param name="form">The form state to serialise.</param>
/// <param name="existingJson">The config document being edited, or null when creating.</param>
/// <returns>The driver's DriverConfig JSON.</returns>
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; }
}
/// <summary>
/// Mutable mirror of the driver's <c>DriverConfig</c> document. Deliberately NOT
/// <c>MTConnectDriverOptions</c>: that type models the parsed options (TimeSpan probe knobs,
/// materialised tag lists), and serialising it would emit <c>"probe":{"interval":"00:00:05"}</c>
/// — which the driver's integer <c>intervalMs</c> DTO cannot bind.
/// </summary>
public sealed class MTConnectFormModel
{
/// <summary>The Agent's base URI. Required by the driver.</summary>
public string AgentUri { get; set; } = "";
/// <summary>Optional single-device scope; blank = agent-wide.</summary>
public string? DeviceName { get; set; }
/// <summary>Per-call HTTP deadline in ms. Must be &gt; 0.</summary>
public int RequestTimeoutMs { get; set; } = 5_000;
/// <summary>The <c>/sample</c> <c>interval</c> query parameter in ms. Must be &gt; 0.</summary>
public int SampleIntervalMs { get; set; } = 1_000;
/// <summary>The <c>/sample</c> <c>count</c> query parameter. Must be &gt; 0.</summary>
public int SampleCount { get; set; } = 1_000;
/// <summary>The <c>/sample</c> <c>heartbeat</c> query parameter in ms. Must be &gt; 0.</summary>
public int HeartbeatMs { get; set; } = 10_000;
/// <summary>Whether the background connectivity probe runs.</summary>
public bool ProbeEnabled { get; set; } = true;
/// <summary>Probe cadence in ms. Must be &gt; 0 while <see cref="ProbeEnabled"/>.</summary>
public int ProbeIntervalMs { get; set; } = 5_000;
/// <summary>Probe request deadline in ms. Must be &gt; 0 while <see cref="ProbeEnabled"/>.</summary>
public int ProbeTimeoutMs { get; set; } = 2_000;
/// <summary>Delay before the first reconnect attempt in ms; 0 = immediate, and legal.</summary>
public int ReconnectMinBackoffMs { get; set; }
/// <summary>Upper bound on the geometric reconnect backoff, in ms.</summary>
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
/// <summary>Multiplier applied to the reconnect backoff each retry.</summary>
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
/// <summary>Loads a form model from a DriverConfig JSON document, falling back to the driver's
/// own defaults for anything absent or unusable.</summary>
/// <param name="json">The DriverConfig JSON, or null/blank/malformed for a fresh form.</param>
/// <returns>The populated form model.</returns>
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,
};
}
/// <summary>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.</summary>
/// <returns>The wire DTO.</returns>
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,
},
};
/// <summary>Returns an operator-facing message describing why this config would not start, or
/// null when it is usable. Advisory: <see cref="ToDto"/> already prevents an unusable value
/// from reaching the driver, so this exists to explain the snap-back rather than to gate save.</summary>
/// <returns>The error message, or null when valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(AgentUri))
{
return "An Agent base URI is required — the driver cannot start without it.";
}
var bad = new List<string>();
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<MTConnectConfigDto>(json, _jsonOpts); }
catch (JsonException) { return null; }
}
}
/// <summary>Mirror of the driver's private <c>MTConnectDriverConfigDto</c> — the exact shape
/// <c>MTConnectDriver.ParseOptions</c> binds. Every member is nullable so an omitted key means "use
/// the driver's default" rather than "reset to zero".</summary>
public sealed class MTConnectConfigDto
{
/// <summary>The Agent's base URI.</summary>
public string? AgentUri { get; init; }
/// <summary>Optional single-device scope.</summary>
public string? DeviceName { get; init; }
/// <summary>Per-call HTTP deadline in ms.</summary>
public int? RequestTimeoutMs { get; init; }
/// <summary>The <c>/sample</c> <c>interval</c> query parameter in ms.</summary>
public int? SampleIntervalMs { get; init; }
/// <summary>The <c>/sample</c> <c>count</c> query parameter.</summary>
public int? SampleCount { get; init; }
/// <summary>The <c>/sample</c> <c>heartbeat</c> query parameter in ms.</summary>
public int? HeartbeatMs { get; init; }
/// <summary>Background connectivity-probe knobs.</summary>
public MTConnectProbeConfigDto? Probe { get; init; }
/// <summary>Reconnect backoff knobs.</summary>
public MTConnectReconnectConfigDto? Reconnect { get; init; }
}
/// <summary>Wire mirror of the driver's probe config block.</summary>
public sealed class MTConnectProbeConfigDto
{
/// <summary>Whether probing runs.</summary>
public bool? Enabled { get; init; }
/// <summary>Probe cadence in ms.</summary>
public int? IntervalMs { get; init; }
/// <summary>Probe request deadline in ms.</summary>
public int? TimeoutMs { get; init; }
}
/// <summary>Wire mirror of the driver's reconnect config block.</summary>
public sealed class MTConnectReconnectConfigDto
{
/// <summary>Delay before the first reconnect attempt, in ms.</summary>
public int? MinBackoffMs { get; init; }
/// <summary>Upper bound on the geometric backoff, in ms.</summary>
public int? MaxBackoffMs { get; init; }
/// <summary>Multiplier applied each retry.</summary>
public double? BackoffMultiplier { get; init; }
}
}
@@ -70,7 +70,8 @@
("MQTT", DriverTypeNames.Mqtt), ("MQTT", DriverTypeNames.Mqtt),
("Galaxy", DriverTypeNames.Galaxy), ("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql), ("Sql", DriverTypeNames.Sql),
("Calculation", "Calculation"), ("Calculation", DriverTypeNames.Calculation),
("MTConnect", DriverTypeNames.MTConnect),
]; ];
private string _type = DriverTypeNames.Modbus; private string _type = DriverTypeNames.Modbus;
@@ -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;
/// <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; }
}
}
@@ -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;
/// <summary>
/// Coverage guard for the <c>/raw</c> "New driver" dialog. <see cref="RawDriverTypeDialog"/> is the
/// ONLY surface that creates a <c>DriverInstance</c>, and its offered types are a hand-authored array
/// — so a driver whose factory is registered but whose entry was never added here is
/// <b>un-creatable in the AdminUI</b> with nothing failing anywhere (the MTConnect gap Task 16 found).
/// This test resolves through <see cref="DriverTypeNames.All"/>, so a newly registered driver type
/// fails here until the dialog offers it.
/// </summary>
/// <remarks>
/// 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 <c>Types</c> table the markup enumerates — the same
/// substitute-for-a-component-test approach <c>DriverPageJsonConverterTests</c> uses.
/// </remarks>
public sealed class RawDriverTypeDialogCoverageTests
{
private static IReadOnlyList<string> 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<string, string>[])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.");
/// <summary>xUnit theory source over every registered driver-type constant.</summary>
public static TheoryData<string> AllDriverTypes()
{
var data = new TheoryData<string>();
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.");
}
}