diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 582438d4..f5a6d5bd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -175,10 +175,49 @@ @* Attribute Overrides *@
-
+
Attribute Overrides + @* 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) + { + + }
+ @if (_csvImportResult is not null) + { +
+
+ @if (_csvImportSucceeded) + { + @_csvImportResult + } + else + { +
@_csvImportResult
+
    + @foreach (var err in _csvImportErrors) + { +
  • @err
  • + } +
+ } +
+
+ } @if (_overrideAttrs.Count == 0) {

No overridable (non-locked) attributes in this template.

@@ -557,6 +596,17 @@ // un-parseable List element caught on the pre-submit round-trip). private Dictionary _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 _csvImportErrors = Array.Empty(); + + // 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 _overridableAlarms = new(); @@ -995,6 +1045,122 @@ _saving = false; } + // ── M7-T16: CSV bulk override import ──────────────────── + + /// + /// Handles a selected override CSV. Reads the file text (size-capped), parses it + /// with the shared , validates every row against + /// the instance's overridable attributes via , + /// and — all-or-nothing — either applies the parsed overrides through the SAME + /// InstanceService.SetAttributeOverrideAsync 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. + /// + private async Task OnCsvImportSelectedAsync(InputFileChangeEventArgs e) + { + _saving = true; + _csvImportResult = null; + _csvImportSucceeded = false; + _csvImportErrors = Array.Empty(); + 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()); + 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()); + 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(); + 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()); + } + finally + { + _saving = false; + } + } + + private void ShowCsvImportFailure(string headline, IReadOnlyList errors) + { + _csvImportSucceeded = false; + _csvImportResult = headline; + _csvImportErrors = errors; + _toast.ShowError(headline); + } + + /// + /// Re-seeds the List editor's working rows for an imported List attribute so the + /// applied value renders immediately. Scalar inputs read _overrideValues + /// directly, so nothing extra is needed for them. + /// + 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) => diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.cs new file mode 100644 index 00000000..d71302c6 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor.cs @@ -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; + +/// +/// Code-behind for InstanceConfigure.razor — 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 , then feeds the result +/// here; on success it applies the returned dict through the SAME +/// InstanceService.SetAttributeOverrideAsync path the manual editor uses. +/// +public partial class InstanceConfigure +{ + /// + /// Outcome of validating a parsed override CSV against an instance's overridable + /// attributes. is the attribute-name → canonical-value-string + /// map to apply (populated ONLY when there are no errors — all-or-nothing); + /// carries parser errors plus per-row validation errors, each + /// pointing back at the operator's source line. + /// + internal sealed record CsvOverrideImportOutcome( + IReadOnlyDictionary Overrides, + IReadOnlyList Errors) + { + /// True when at least one parser or validation error was collected. + public bool HasErrors => Errors.Count > 0; + } + + /// + /// 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. + /// + /// Rules (mirroring the manual editor): + /// + /// The attribute name must exist in . + /// That set is the page's _overrideAttrs (already filtered to + /// non-template-locked rows), so a locked / unknown attribute is rejected + /// exactly as the editor would never offer it. + /// The value must be type-compatible with the attribute's + /// DataType/ElementDataType, validated by round-tripping through + /// — the same codec the List editor uses. + /// A null value ("clear the override") is always accepted; downstream the + /// set-override path treats a null/empty value as a clear. + /// + /// + /// All-or-nothing: if ANY parser or validation error is present, the + /// returned is empty so the + /// caller applies nothing and surfaces the full error list. + /// + /// The result of . + /// The instance's non-locked attributes (the page's _overrideAttrs). + internal static CsvOverrideImportOutcome BuildCsvOverrideImport( + OverrideCsvParseResult parsed, + IReadOnlyList overridableAttributes) + { + var errors = new List(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(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(StringComparer.Ordinal), errors); + + return new CsvOverrideImportOutcome(overrides, Array.Empty()); + } + + /// + /// Validates a single non-null override value against the attribute's declared + /// type using . Returns a line-qualified error + /// string when incompatible, otherwise null. + /// + 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 { 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; + } + + /// + /// 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. + /// + private static bool ScalarIsTypeChecked(DataType dt) => + dt is DataType.Int32 or DataType.Float or DataType.Double + or DataType.Boolean or DataType.DateTime; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs new file mode 100644 index 00000000..efd4f9cc --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureCsvImportTests.cs @@ -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; + +/// +/// M7-T16: the Instance Configure page accepts a CSV of attribute overrides via +/// an <InputFile>, parses it with the shared +/// , validates each row against the instance's +/// overridable attributes (name must exist + not be template-locked, value must be +/// type-compatible — reusing ), and — all-or-nothing +/// — either submits the parsed override dict through the SAME +/// InstanceService.SetAttributeOverrideAsync path the manual editor already +/// uses, or shows the per-line error list (parser + validation) without applying +/// anything. +/// +/// +/// InstanceConfigure is a heavyweight page (≈7 injected services incl. +/// InstanceService + 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 internal static helper exercised directly here, +/// plus structural assertions over the component source that pin the InputFile + +/// reuse-the-existing-submit-path wiring. +/// +/// +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 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>(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("