feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate #506
@@ -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;
|
||||||
|
|||||||
+406
@@ -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 > 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 > 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 > 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 > 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 > 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 > 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 > 0.</summary>
|
||||||
|
public int RequestTimeoutMs { get; set; } = 5_000;
|
||||||
|
/// <summary>The <c>/sample</c> <c>interval</c> query parameter in ms. Must be > 0.</summary>
|
||||||
|
public int SampleIntervalMs { get; set; } = 1_000;
|
||||||
|
/// <summary>The <c>/sample</c> <c>count</c> query parameter. Must be > 0.</summary>
|
||||||
|
public int SampleCount { get; set; } = 1_000;
|
||||||
|
/// <summary>The <c>/sample</c> <c>heartbeat</c> query parameter in ms. Must be > 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 > 0 while <see cref="ProbeEnabled"/>.</summary>
|
||||||
|
public int ProbeIntervalMs { get; set; } = 5_000;
|
||||||
|
/// <summary>Probe request deadline in ms. Must be > 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user