feat(centralui): InstanceConfigure CSV bulk override import (T16)

This commit is contained in:
Joseph Doherty
2026-06-18 02:30:33 -04:00
parent 25c9240415
commit d5e7e897c0
3 changed files with 497 additions and 1 deletions
@@ -175,10 +175,49 @@
@* Attribute Overrides *@
<div class="card mb-3">
<div class="card-header py-2">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Attribute Overrides</strong>
@* M7-T16: bulk import of attribute overrides from a CSV
(AttributeName,Value[,ElementType]). Selecting a file parses +
validates it against this instance's overridable attributes and —
all-or-nothing — applies every row through the SAME
SetAttributeOverrideAsync path the manual editor uses, or shows the
per-line error list and applies nothing. *@
@if (_overrideAttrs.Count > 0)
{
<label class="btn btn-outline-secondary btn-sm mb-0">
Import overrides (CSV)
<InputFile OnChange="OnCsvImportSelectedAsync"
accept=".csv"
data-test="csv-import-input"
class="d-none"
disabled="@_saving" />
</label>
}
</div>
<div class="card-body p-0">
@if (_csvImportResult is not null)
{
<div class="p-2 pb-0">
<div class="alert @(_csvImportSucceeded ? "alert-success" : "alert-danger") small mb-0"
data-test="csv-import-result">
@if (_csvImportSucceeded)
{
<span>@_csvImportResult</span>
}
else
{
<div class="fw-semibold mb-1">@_csvImportResult</div>
<ul class="mb-0 ps-3">
@foreach (var err in _csvImportErrors)
{
<li>@err</li>
}
</ul>
}
</div>
</div>
}
@if (_overrideAttrs.Count == 0)
{
<p class="text-muted small p-3 mb-0">No overridable (non-locked) attributes in this template.</p>
@@ -557,6 +596,17 @@
// un-parseable List element caught on the pre-submit round-trip).
private Dictionary<string, string> _overrideErrors = new();
// M7-T16: CSV bulk-import result summary. _csvImportResult is the headline
// ("Imported N overrides." or "Import rejected — N error(s)."); on failure the
// per-line messages are listed from _csvImportErrors. Null until an import runs.
private string? _csvImportResult;
private bool _csvImportSucceeded;
private IReadOnlyList<string> _csvImportErrors = Array.Empty<string>();
// Reject pathologically large uploads before buffering — a few hundred KB of
// override CSV is already extreme (thousands of attributes).
private const long MaxCsvImportBytes = 512 * 1024;
// Alarm overrides — read-only state pulled from the repo. The edit modal
// is the only mutation path (one alarm at a time).
private List<TemplateAlarm> _overridableAlarms = new();
@@ -995,6 +1045,122 @@
_saving = false;
}
// ── M7-T16: CSV bulk override import ────────────────────
/// <summary>
/// Handles a selected override CSV. Reads the file text (size-capped), parses it
/// with the shared <see cref="OverrideCsvParser"/>, validates every row against
/// the instance's overridable attributes via <see cref="BuildCsvOverrideImport"/>,
/// and — all-or-nothing — either applies the parsed overrides through the SAME
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses, or
/// shows the per-line error list and applies nothing. The override editor is
/// refreshed in-place so the applied values are immediately visible.
/// </summary>
private async Task OnCsvImportSelectedAsync(InputFileChangeEventArgs e)
{
_saving = true;
_csvImportResult = null;
_csvImportSucceeded = false;
_csvImportErrors = Array.Empty<string>();
try
{
var file = e.File;
if (file.Size > MaxCsvImportBytes)
{
ShowCsvImportFailure(
$"File too large ({file.Size:N0} bytes). The maximum is {MaxCsvImportBytes:N0} bytes.",
Array.Empty<string>());
return;
}
string text;
using (var reader = new StreamReader(file.OpenReadStream(MaxCsvImportBytes)))
{
text = await reader.ReadToEndAsync();
}
var parsed = OverrideCsvParser.Parse(text);
var outcome = BuildCsvOverrideImport(parsed, _overrideAttrs);
if (outcome.HasErrors)
{
ShowCsvImportFailure(
$"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.",
outcome.Errors);
return;
}
if (outcome.Overrides.Count == 0)
{
ShowCsvImportFailure("No override rows found in the file.", Array.Empty<string>());
return;
}
// Apply through the EXISTING per-attribute submit path (no new server
// method) — identical to SaveOverrides. Update the editor's in-memory
// state so the applied values render immediately.
var user = await GetCurrentUserAsync();
var failures = new List<string>();
foreach (var (attrName, value) in outcome.Overrides)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
if (result.IsSuccess)
{
_existingOverrides[attrName] = result.Value!;
if (value is null) _overrideValues.Remove(attrName);
else _overrideValues[attrName] = value;
RefreshEditorRowForImport(attrName, value);
}
else
{
failures.Add($"{attrName}: {result.Error}");
}
}
if (failures.Count > 0)
{
ShowCsvImportFailure(
$"Applied {outcome.Overrides.Count - failures.Count} of {outcome.Overrides.Count}; "
+ $"{failures.Count} failed.",
failures);
}
else
{
_csvImportSucceeded = true;
_csvImportResult = $"Imported {outcome.Overrides.Count} override(s).";
_toast.ShowSuccess(_csvImportResult);
}
}
catch (Exception ex)
{
ShowCsvImportFailure($"Import failed: {ex.Message}", Array.Empty<string>());
}
finally
{
_saving = false;
}
}
private void ShowCsvImportFailure(string headline, IReadOnlyList<string> errors)
{
_csvImportSucceeded = false;
_csvImportResult = headline;
_csvImportErrors = errors;
_toast.ShowError(headline);
}
/// <summary>
/// Re-seeds the List editor's working rows for an imported List attribute so the
/// applied value renders immediately. Scalar inputs read <c>_overrideValues</c>
/// directly, so nothing extra is needed for them.
/// </summary>
private void RefreshEditorRowForImport(string attrName, string? value)
{
var attr = _overrideAttrs.FirstOrDefault(a => a.Name == attrName);
if (attr?.DataType == DataType.List)
_listRows[attrName] = DecodeListRows(value, attr.ElementDataType);
}
// ── Alarm overrides ─────────────────────────────────────
private bool HasOverride(string alarmName) =>
@@ -0,0 +1,153 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// <summary>
/// Code-behind for <c>InstanceConfigure.razor</c> — hosts the pure, side-effect-free
/// core of the CSV bulk-override import (M7-T16) so it can be unit-pinned without
/// standing up the page's ≈7 injected services. The Razor side reads the uploaded
/// file's text, calls <see cref="OverrideCsvParser.Parse"/>, then feeds the result
/// here; on success it applies the returned dict through the SAME
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses.
/// </summary>
public partial class InstanceConfigure
{
/// <summary>
/// Outcome of validating a parsed override CSV against an instance's overridable
/// attributes. <see cref="Overrides"/> is the attribute-name → canonical-value-string
/// map to apply (populated ONLY when there are no errors — all-or-nothing);
/// <see cref="Errors"/> carries parser errors plus per-row validation errors, each
/// pointing back at the operator's source line.
/// </summary>
internal sealed record CsvOverrideImportOutcome(
IReadOnlyDictionary<string, string?> Overrides,
IReadOnlyList<string> Errors)
{
/// <summary>True when at least one parser or validation error was collected.</summary>
public bool HasErrors => Errors.Count > 0;
}
/// <summary>
/// Validates a parsed override CSV against the instance's overridable attributes
/// and, if everything checks out, builds the override dict to submit. Pure and
/// deterministic — no I/O, no page state.
///
/// <para>Rules (mirroring the manual editor):</para>
/// <list type="bullet">
/// <item>The attribute name must exist in <paramref name="overridableAttributes"/>.
/// That set is the page's <c>_overrideAttrs</c> (already filtered to
/// non-template-locked rows), so a locked / unknown attribute is rejected
/// exactly as the editor would never offer it.</item>
/// <item>The value must be type-compatible with the attribute's
/// <c>DataType</c>/<c>ElementDataType</c>, validated by round-tripping through
/// <see cref="AttributeValueCodec"/> — the same codec the List editor uses.</item>
/// <item>A null value ("clear the override") is always accepted; downstream the
/// set-override path treats a null/empty value as a clear.</item>
/// </list>
///
/// <para>All-or-nothing: if ANY parser or validation error is present, the
/// returned <see cref="CsvOverrideImportOutcome.Overrides"/> is empty so the
/// caller applies nothing and surfaces the full error list.</para>
/// </summary>
/// <param name="parsed">The result of <see cref="OverrideCsvParser.Parse"/>.</param>
/// <param name="overridableAttributes">The instance's non-locked attributes (the page's <c>_overrideAttrs</c>).</param>
internal static CsvOverrideImportOutcome BuildCsvOverrideImport(
OverrideCsvParseResult parsed,
IReadOnlyList<TemplateAttribute> overridableAttributes)
{
var errors = new List<string>(parsed.Errors);
// Only non-template-locked attributes are overridable. The page already
// passes its pre-filtered _overrideAttrs, but re-filter here so the rule is
// self-contained and a locked attribute is rejected even if a caller passes
// the raw template attribute list.
var byName = overridableAttributes
.Where(a => !a.IsLocked)
.ToDictionary(a => a.Name, StringComparer.Ordinal);
var overrides = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var row in parsed.Rows)
{
if (!byName.TryGetValue(row.AttributeName, out var attr))
{
errors.Add(
$"Line {row.LineNumber}: attribute '{row.AttributeName}' is not an "
+ "overridable attribute on this instance (unknown or template-locked).");
continue;
}
// A null value clears the override — always valid, no type check needed.
if (row.Value is null)
{
overrides[row.AttributeName] = null;
continue;
}
// Type compatibility: round-trip through the shared codec. For List
// attributes the value is canonical JSON validated against the element
// type fixed by the base attribute; for scalars we validate the literal
// against the declared type. Mirrors the manual editor's guard.
var error = ValidateValue(attr, row.Value, row.LineNumber);
if (error is not null)
{
errors.Add(error);
continue;
}
overrides[row.AttributeName] = row.Value;
}
// All-or-nothing: any error means nothing is applied.
if (errors.Count > 0)
return new CsvOverrideImportOutcome(
new Dictionary<string, string?>(StringComparer.Ordinal), errors);
return new CsvOverrideImportOutcome(overrides, Array.Empty<string>());
}
/// <summary>
/// Validates a single non-null override value against the attribute's declared
/// type using <see cref="AttributeValueCodec"/>. Returns a line-qualified error
/// string when incompatible, otherwise null.
/// </summary>
private static string? ValidateValue(TemplateAttribute attr, string value, int lineNumber)
{
try
{
if (attr.DataType == DataType.List)
{
// Canonical JSON array; Decode throws FormatException on malformed
// JSON or an un-parseable element against the fixed element type.
AttributeValueCodec.Decode(value, DataType.List, attr.ElementDataType ?? DataType.String);
}
else
{
// Scalar: validate the literal parses to the declared type. The codec
// returns scalars unchanged, so we use the List machinery with a
// single-element array to reuse its per-type parse rules.
if (ScalarIsTypeChecked(attr.DataType))
{
var probeJson = AttributeValueCodec.Encode(new List<string> { value });
AttributeValueCodec.Decode(probeJson, DataType.List, attr.DataType);
}
}
}
catch (FormatException ex)
{
return $"Line {lineNumber}: attribute '{attr.Name}' value is not a valid "
+ $"{attr.DataType}{(attr.DataType == DataType.List ? $" of {attr.ElementDataType}" : string.Empty)} — {ex.Message}";
}
return null;
}
/// <summary>
/// True for scalar types the codec can validate. String accepts any literal (no
/// check); the remaining scalar types are parse-checked. Object/Binary aren't
/// overridable scalars in this editor, so they pass through unchecked.
/// </summary>
private static bool ScalarIsTypeChecked(DataType dt) =>
dt is DataType.Int32 or DataType.Float or DataType.Double
or DataType.Boolean or DataType.DateTime;
}
@@ -0,0 +1,177 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components;
/// <summary>
/// M7-T16: the Instance Configure page accepts a CSV of attribute overrides via
/// an <c>&lt;InputFile&gt;</c>, parses it with the shared
/// <see cref="OverrideCsvParser"/>, validates each row against the instance's
/// overridable attributes (name must exist + not be template-locked, value must be
/// type-compatible — reusing <see cref="AttributeValueCodec"/>), and — all-or-nothing
/// — either submits the parsed override dict through the SAME
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor already
/// uses, or shows the per-line error list (parser + validation) without applying
/// anything.
///
/// <para>
/// <c>InstanceConfigure</c> is a heavyweight page (≈7 injected services incl.
/// <c>InstanceService</c> + the flattening pipeline), so — consistent with the
/// MV-14 List-override and native-alarm coverage — the parse→validate→build-dict
/// core is extracted to an <c>internal static</c> helper exercised directly here,
/// plus structural assertions over the component source that pin the InputFile +
/// reuse-the-existing-submit-path wiring.
/// </para>
/// </summary>
public class InstanceConfigureCsvImportTests
{
private static string InstanceConfigureMarkup
{
get
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6 && dir is not null; i++)
dir = Directory.GetParent(dir)?.FullName;
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
"Components", "Pages", "Deployment", "InstanceConfigure.razor"));
}
}
private static List<TemplateAttribute> Attributes() => new()
{
new TemplateAttribute("Setpoint") { DataType = DataType.Float, IsLocked = false },
new TemplateAttribute("Name") { DataType = DataType.String, IsLocked = false },
new TemplateAttribute("Counts") { DataType = DataType.List, ElementDataType = DataType.Int32, IsLocked = false },
new TemplateAttribute("SerialNumber") { DataType = DataType.String, IsLocked = true },
};
// ── Core: valid CSV → override dict, no errors ──────────────────────────
[Fact]
public void ValidCsv_BuildsOverrideDict_WithNoErrors()
{
var csv = "AttributeName,Value\nSetpoint,12.5\nName,Pump-A\n";
var parsed = OverrideCsvParser.Parse(csv);
var outcome = InstanceConfigure.BuildCsvOverrideImport(parsed, Attributes());
Assert.False(outcome.HasErrors);
Assert.Empty(outcome.Errors);
Assert.Equal(2, outcome.Overrides.Count);
Assert.Equal("12.5", outcome.Overrides["Setpoint"]);
Assert.Equal("Pump-A", outcome.Overrides["Name"]);
}
[Fact]
public void ValidListCsv_BuildsCanonicalJson_PerCodecRoundTrip()
{
// A List attribute value is supplied as canonical JSON in the Value column
// and round-trips through AttributeValueCodec just like the manual editor.
var csv = "AttributeName,Value\nCounts,\"[1,2,3]\"\n";
var parsed = OverrideCsvParser.Parse(csv);
var outcome = InstanceConfigure.BuildCsvOverrideImport(parsed, Attributes());
Assert.False(outcome.HasErrors);
var decoded = AttributeValueCodec.Decode(outcome.Overrides["Counts"], DataType.List, DataType.Int32);
Assert.Equal(new[] { 1, 2, 3 }, Assert.IsType<List<int>>(decoded));
}
// ── Core: bad rows → errors, NO dict (all-or-nothing) ───────────────────
[Fact]
public void UnknownAttribute_ProducesError_AndAppliesNothing()
{
var csv = "AttributeName,Value\nDoesNotExist,42\nSetpoint,1.0\n";
var parsed = OverrideCsvParser.Parse(csv);
var outcome = InstanceConfigure.BuildCsvOverrideImport(parsed, Attributes());
Assert.True(outcome.HasErrors);
Assert.Empty(outcome.Overrides); // all-or-nothing: nothing applied
Assert.Contains(outcome.Errors, e => e.Contains("DoesNotExist") && e.Contains("Line 2"));
}
[Fact]
public void TemplateLockedAttribute_IsRejected_LikeTheEditor()
{
// SerialNumber is IsLocked → excluded from the overridable set, so a CSV
// targeting it is rejected exactly as the manual editor would never list it.
var csv = "AttributeName,Value\nSerialNumber,ABC123\n";
var parsed = OverrideCsvParser.Parse(csv);
var outcome = InstanceConfigure.BuildCsvOverrideImport(parsed, Attributes());
Assert.True(outcome.HasErrors);
Assert.Empty(outcome.Overrides);
Assert.Contains(outcome.Errors, e => e.Contains("SerialNumber"));
}
[Fact]
public void TypeIncompatibleValue_ProducesError_AndAppliesNothing()
{
// "not-a-number" is not a valid Float.
var csv = "AttributeName,Value\nSetpoint,not-a-number\nName,ok\n";
var parsed = OverrideCsvParser.Parse(csv);
var outcome = InstanceConfigure.BuildCsvOverrideImport(parsed, Attributes());
Assert.True(outcome.HasErrors);
Assert.Empty(outcome.Overrides);
Assert.Contains(outcome.Errors, e => e.Contains("Setpoint") && e.Contains("Line 2"));
}
[Fact]
public void MalformedListJson_ProducesError_AndAppliesNothing()
{
var csv = "AttributeName,Value\nCounts,\"[1,oops]\"\n";
var parsed = OverrideCsvParser.Parse(csv);
var outcome = InstanceConfigure.BuildCsvOverrideImport(parsed, Attributes());
Assert.True(outcome.HasErrors);
Assert.Empty(outcome.Overrides);
Assert.Contains(outcome.Errors, e => e.Contains("Counts"));
}
[Fact]
public void ParserErrors_PropagateThrough_AndApplyNothing()
{
// A bad header makes the parser emit an error and zero rows; the import
// must surface that error and apply nothing.
var csv = "Wrong,Header\nSetpoint,1.0\n";
var parsed = OverrideCsvParser.Parse(csv);
var outcome = InstanceConfigure.BuildCsvOverrideImport(parsed, Attributes());
Assert.True(outcome.HasErrors);
Assert.Empty(outcome.Overrides);
Assert.NotEmpty(outcome.Errors);
}
// ── Structural: InputFile + reuse-the-existing-submit-path wiring ───────
[Fact]
public void Page_WiresCsvInputFile_WithTestHooks()
{
var markup = InstanceConfigureMarkup;
Assert.Contains("<InputFile", markup);
Assert.Contains("data-test=\"csv-import-input\"", markup);
Assert.Contains("data-test=\"csv-import-result\"", markup);
Assert.Contains("accept=\".csv\"", markup);
// File-selected handler reads + parses the upload.
Assert.Contains("OverrideCsvParser.Parse", markup);
}
[Fact]
public void Page_AppliesViaExistingSetAttributeOverridePath()
{
var markup = InstanceConfigureMarkup;
// Reuse the EXACT submit path the manual editor uses — no new server method.
Assert.Contains("InstanceService.SetAttributeOverrideAsync", markup);
// The import calls the shared core.
Assert.Contains("BuildCsvOverrideImport", markup);
}
}