From a26d6ba317b95ad4899f9d2698aa4853613adebe Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 11:11:06 -0400 Subject: [PATCH] feat(central-ui): native-alarm-source CSV import on InstanceConfigure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Native Alarm Source Overrides card had no bulk affordance — the CSV path shipped CLI-only (instance native-alarm-source import --file), so the UI could only retarget one source at a time inline. Adds a second InputFile on that card, mirroring the attribute importer's UX (hidden input behind a button-styled label, 512 KB cap, success/error alert with the per-line error list, toast). Parsing reuses the SHARED NativeAlarmSourceOverrideCsvParser — the exact parser the CLI uses — and the new pure InstanceConfigure.BuildNativeAlarmSourceCsvImport applies the same batch rules the server enforces in ManagementActor.HandleSetInstanceNativeAlarmSourceOverrides: the source must resolve, must not be template-locked, and may appear at most once; any error rejects the whole file and applies nothing. Semantics match the CLI: merge, not full replace — sources absent from the file keep their existing override, a blank field keeps the inherited value, and an all-blank row clears that source's override (equivalent to the CLI's all-null override row, without leaving a dead row behind). Persistence reuses the inline editor's path: SaveNativeOverride's upsert body is extracted to UpsertNativeOverrideCore (no SaveChangesAsync inside), so the import commits the validated batch in one SaveChangesAsync — no new server method, no duplicated parsing. Tests: InstanceConfigureNativeAlarmCsvImportTests (9) — happy path, blank-field inheritance, all-blank clear, merge semantics, unknown/locked/duplicate source and parser-error rejection, plus structural pins on the InputFile wiring. Docs: Component-CentralUI.md native-alarm-source card gains the import bullet. --- docs/requirements/Component-CentralUI.md | 1 + .../Pages/Deployment/InstanceConfigure.razor | 212 +++++++++++++++--- .../Deployment/InstanceConfigure.razor.cs | 114 ++++++++++ ...tanceConfigureNativeAlarmCsvImportTests.cs | 199 ++++++++++++++++ 4 files changed, 497 insertions(+), 29 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 48c8fe52..e94f6b9d 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -125,6 +125,7 @@ Central cluster only. Sites have no user interface. - **Source Reference** — the concrete native key for this instance. - **Filter** — the per-instance condition filter. - A **blank field inherits** the template default (the greyed placeholder shows the inherited value for context, mirroring the per-attribute Override field). **Save** and **Clear** act per row — Save persists the row's overrides, Clear reverts the row to the template-inherited binding. Locked template sources are not overridable. + - **Bulk retarget CSV import**: an `InputFile` upload on the card header accepts a CSV of `SourceName, Connection, SourceReference, Filter` rows — the same file format and batch rules as the CLI `instance native-alarm-source import --file` (see Component-CLI.md), parsed with the shared `NativeAlarmSourceOverrideCsvParser`. Each row is validated against the template's source bindings (name resolves, source not template-locked, no duplicate source in the file); the import is **all-or-nothing** — any error aborts the whole upload with a per-line error summary and nothing is applied. Semantics are **merge, not full replace**: sources absent from the file keep their existing override, a blank field keeps the inherited value, and a row with all three fields blank clears that source's override. On success the rows are upserted through the same repository path the inline Save uses. - Filter/search instances by site, area, template, or status. - **Disable** instances — stops data collection, script triggers, and alarm evaluation at the site while retaining the deployed configuration. - **Enable** instances — re-activates a disabled instance. 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 84270fc4..bf5a44e6 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 @@ -431,14 +431,59 @@ @* Native Alarm Source Overrides *@
-
- Native Alarm Source Overrides - - Retarget an inherited native alarm source binding for this instance. - Leave a field blank to keep the inherited value. - +
+
+ Native Alarm Source Overrides + + Retarget an inherited native alarm source binding for this instance. + Leave a field blank to keep the inherited value. + +
+ @* Bulk import of native alarm source retargets from a CSV + (SourceName,Connection,SourceReference,Filter) — the SAME file + format and batch rules as the CLI + `instance native-alarm-source import --file`. Selecting a file + parses it with the shared NativeAlarmSourceOverrideCsvParser and + validates every row against this template's sources; all-or-nothing, + it either upserts each row through the SAME repository path the + inline Save uses, or shows the per-line error list and applies + nothing. Merge semantics — sources absent from the file keep their + existing override. *@ + @if (_nativeSources.Count > 0) + { + + }
+ @if (_nasCsvImportResult is not null) + { +
+
+ @if (_nasCsvImportSucceeded) + { + @_nasCsvImportResult + } + else + { +
@_nasCsvImportResult
+
    + @foreach (var err in _nasCsvImportErrors) + { +
  • @err
  • + } +
+ } +
+
+ } @if (_nativeSources.Count == 0) {

No native alarm sources on this template.

@@ -623,6 +668,12 @@ private Dictionary _nasRefEdit = new(); private Dictionary _nasFilterEdit = new(); + // Native-alarm-source CSV bulk-import result summary — same shape as the + // attribute importer's (_csvImport*) trio. Null until an import runs. + private string? _nasCsvImportResult; + private bool _nasCsvImportSucceeded; + private IReadOnlyList _nasCsvImportErrors = Array.Empty(); + // Override edit modal state — non-null while the modal is open. private TemplateAlarm? _editingAlarm; private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor @@ -1368,29 +1419,8 @@ return; } - var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); - if (existing == null) - { - var ovr = new InstanceNativeAlarmSourceOverride(sourceName) - { - InstanceId = Id, - ConnectionNameOverride = conn, - SourceReferenceOverride = sref, - ConditionFilterOverride = filt - }; - await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr); - await TemplateEngineRepository.SaveChangesAsync(); - _existingNativeOverrides[sourceName] = ovr; - } - else - { - existing.ConnectionNameOverride = conn; - existing.SourceReferenceOverride = sref; - existing.ConditionFilterOverride = filt; - await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing); - await TemplateEngineRepository.SaveChangesAsync(); - _existingNativeOverrides[sourceName] = existing; - } + await UpsertNativeOverrideCore(sourceName, conn, sref, filt); + await TemplateEngineRepository.SaveChangesAsync(); _toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'."); } catch (Exception ex) @@ -1415,6 +1445,42 @@ _saving = false; } + /// + /// Upserts one native alarm source override row and re-seeds the inline editor's + /// state for that source. Does NOT call SaveChangesAsync — the caller does, + /// so a bulk import commits the whole batch in one go. Shared by the inline Save + /// and the CSV import so both take the identical persistence path. + /// + private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt) + { + var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); + if (existing == null) + { + var ovr = new InstanceNativeAlarmSourceOverride(sourceName) + { + InstanceId = Id, + ConnectionNameOverride = conn, + SourceReferenceOverride = sref, + ConditionFilterOverride = filt + }; + await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr); + _existingNativeOverrides[sourceName] = ovr; + } + else + { + existing.ConnectionNameOverride = conn; + existing.SourceReferenceOverride = sref; + existing.ConditionFilterOverride = filt; + await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing); + _existingNativeOverrides[sourceName] = existing; + } + + // Keep the inline editor showing what was just persisted. + _nasConnEdit[sourceName] = conn; + _nasRefEdit[sourceName] = sref; + _nasFilterEdit[sourceName] = filt; + } + private async Task ClearNativeOverrideCore(string sourceName) { var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); @@ -1431,6 +1497,94 @@ private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); + // ── Native alarm source CSV bulk import ───────────────── + + /// + /// Handles a selected native-alarm-source-override CSV. Reads the file text + /// (size-capped the same way as the attribute importer), parses it with the shared + /// — the exact parser the CLI + /// instance native-alarm-source import --file command uses — validates every + /// row via , and — all-or-nothing — + /// either upserts each row through the SAME repository path the inline Save uses, + /// or shows the per-line error list and applies nothing. Merge semantics, matching + /// the CLI: sources absent from the file keep their existing override, a blank + /// field keeps the inherited value, and a row with all three fields blank clears + /// that source's override. + /// + private async Task OnNativeAlarmSourceCsvImportSelectedAsync(InputFileChangeEventArgs e) + { + _saving = true; + _nasCsvImportResult = null; + _nasCsvImportSucceeded = false; + _nasCsvImportErrors = Array.Empty(); + try + { + var file = e.File; + if (file.Size > MaxCsvImportBytes) + { + ShowNativeCsvImportFailure( + $"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 = NativeAlarmSourceOverrideCsvParser.Parse(text); + var outcome = BuildNativeAlarmSourceCsvImport(parsed, _nativeSources); + + if (outcome.HasErrors) + { + ShowNativeCsvImportFailure( + $"Import rejected — {outcome.Errors.Count} error(s); no overrides applied.", + outcome.Errors); + return; + } + + if (outcome.Rows.Count == 0) + { + ShowNativeCsvImportFailure("No override rows found in the file.", Array.Empty()); + return; + } + + // Every row was validated up front, so the batch applies as a unit through + // the same repository upsert/clear the inline editor uses. + foreach (var row in outcome.Rows) + { + if (row.IsClear) + await ClearNativeOverrideCore(row.SourceName); + else + await UpsertNativeOverrideCore( + row.SourceName, row.Connection, row.SourceReference, row.Filter); + } + await TemplateEngineRepository.SaveChangesAsync(); + + _nasCsvImportSucceeded = true; + _nasCsvImportResult = $"Imported {outcome.Rows.Count} native alarm source override(s)."; + _toast.ShowSuccess(_nasCsvImportResult); + } + catch (Exception ex) + { + ShowNativeCsvImportFailure($"Import failed: {ex.Message}", Array.Empty()); + } + finally + { + _saving = false; + } + } + + private void ShowNativeCsvImportFailure(string headline, IReadOnlyList errors) + { + _nasCsvImportSucceeded = false; + _nasCsvImportResult = headline; + _nasCsvImportErrors = errors; + _toast.ShowError(headline); + } + /// /// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum /// to the canonical SCADA type string the AlarmTriggerEditor compares 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 index fd2971e8..833ec62d 100644 --- 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 @@ -11,6 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; /// 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. +/// +/// The native-alarm-source CSV import () +/// follows the identical shape: the Razor side parses the upload with the shared +/// — the same parser the CLI +/// instance native-alarm-source import --file command uses — validates it here, +/// then upserts each row through the SAME repository path the inline editor's Save +/// uses. /// public partial class InstanceConfigure { @@ -108,6 +115,113 @@ public partial class InstanceConfigure return new CsvOverrideImportOutcome(overrides, Array.Empty()); } + /// + /// One validated native-alarm-source retarget from an imported CSV: the source + /// binding's name plus the three optional override fields (null = keep the + /// inherited value). A row whose three fields are ALL null means "no override" — + /// the caller clears any existing override row for that source, exactly as the + /// inline editor's Save does when every field is left blank. + /// + internal sealed record NativeAlarmSourceCsvImportRow( + string SourceName, string? Connection, string? SourceReference, string? Filter) + { + /// True when all three override fields are blank (row means "inherit everything"). + public bool IsClear => Connection is null && SourceReference is null && Filter is null; + } + + /// + /// Outcome of validating a parsed native-alarm-source-override CSV against an + /// instance's template source bindings. is the ordered list of + /// retargets 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 NativeAlarmSourceCsvImportOutcome( + IReadOnlyList Rows, + IReadOnlyList Errors) + { + /// True when at least one parser or validation error was collected. + public bool HasErrors => Errors.Count > 0; + } + + /// + /// Validates a parsed native-alarm-source-override CSV against the template's + /// source bindings and, if everything checks out, builds the retarget list to + /// apply. Pure and deterministic — no I/O, no page state. + /// + /// Rules — the same batch rules the CLI's + /// instance native-alarm-source import --file path enforces server-side in + /// ManagementActor.HandleSetInstanceNativeAlarmSourceOverrides: + /// + /// The source name must resolve for this instance — an unknown name would + /// create a dead override the flattener silently drops. + /// The source must not be template-locked. + /// A source may appear at most once in the batch — a duplicate makes the + /// intended override ambiguous and the last-writer-wins upsert would silently + /// discard the earlier row. + /// + /// + /// Semantics are merge, not full replace (matching the CLI): sources + /// absent from the CSV keep whatever override they already have, and a blank field + /// within a row keeps the inherited value. + /// + /// 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 template's native alarm source bindings (the page's _nativeSources). + /// The validated retarget outcome; + /// is empty and errors are populated when any row fails validation. + internal static NativeAlarmSourceCsvImportOutcome BuildNativeAlarmSourceCsvImport( + NativeAlarmSourceOverrideCsvParseResult parsed, + IReadOnlyList templateSources) + { + var errors = new List(parsed.Errors); + var byName = templateSources.ToDictionary(s => s.Name, StringComparer.Ordinal); + var rows = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var row in parsed.Rows) + { + if (!byName.TryGetValue(row.SourceName, out var source)) + { + errors.Add( + $"Line {row.LineNumber}: native alarm source '{row.SourceName}' does not " + + "resolve for this instance and cannot be overridden."); + continue; + } + + if (source.IsLocked) + { + errors.Add( + $"Line {row.LineNumber}: native alarm source '{row.SourceName}' is locked at " + + "the template level and cannot be overridden."); + continue; + } + + if (!seen.Add(row.SourceName)) + { + errors.Add( + $"Line {row.LineNumber}: native alarm source '{row.SourceName}' appears more " + + "than once in the file."); + continue; + } + + rows.Add(new NativeAlarmSourceCsvImportRow( + row.SourceName, + Blank(row.Connection), + Blank(row.SourceReference), + Blank(row.Filter))); + } + + // All-or-nothing: any error means nothing is applied. + if (errors.Count > 0) + return new NativeAlarmSourceCsvImportOutcome(Array.Empty(), errors); + + return new NativeAlarmSourceCsvImportOutcome(rows, Array.Empty()); + } + /// /// Validates a single non-null override value against the attribute's declared /// type using . Returns a line-qualified error diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs new file mode 100644 index 00000000..f63f3c5c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/InstanceConfigureNativeAlarmCsvImportTests.cs @@ -0,0 +1,199 @@ +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Types; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components; + +/// +/// The Instance Configure page's Native Alarm Source Overrides card accepts a CSV of +/// per-instance retargets via an <InputFile> — the UI half of the CLI's +/// instance native-alarm-source import --file. The upload is parsed with the +/// SHARED (no duplicated parsing), +/// validated against the template's source bindings with the same batch rules the +/// server enforces (name resolves, not template-locked, no duplicate source), and — +/// all-or-nothing — either upserted through the SAME repository path the inline Save +/// uses or rejected with the per-line error list and nothing applied. +/// +/// +/// InstanceConfigure is a heavyweight page (≈7 injected services incl. the +/// flattening pipeline), so — consistent with the attribute-importer and native-alarm +/// card coverage — the parse→validate→build-rows 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-save-path +/// wiring. +/// +/// +public class InstanceConfigureNativeAlarmCsvImportTests +{ + 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 Sources() => new() + { + new TemplateNativeAlarmSource("BoilerAlarms") + { + ConnectionName = "opc-a", SourceReference = "ns=2;s=Boiler", IsLocked = false + }, + new TemplateNativeAlarmSource("PumpAlarms") + { + ConnectionName = "opc-a", SourceReference = "ns=2;s=Pump", IsLocked = false + }, + new TemplateNativeAlarmSource("LockedAlarms") + { + ConnectionName = "opc-a", SourceReference = "ns=2;s=Locked", IsLocked = true + }, + }; + + // ── Core: valid CSV → rows, no errors ─────────────────────────────────── + + [Fact] + public void ValidCsv_BuildsRetargetRows_WithNoErrors() + { + var csv = "SourceName,Connection,SourceReference,Filter\n" + + "BoilerAlarms,opc-b,ns=2;s=Boiler2,HighHigh\n" + + "PumpAlarms,,ns=2;s=Pump2,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.False(outcome.HasErrors); + Assert.Empty(outcome.Errors); + Assert.Equal(2, outcome.Rows.Count); + + var boiler = outcome.Rows[0]; + Assert.Equal("BoilerAlarms", boiler.SourceName); + Assert.Equal("opc-b", boiler.Connection); + Assert.Equal("ns=2;s=Boiler2", boiler.SourceReference); + Assert.Equal("HighHigh", boiler.Filter); + Assert.False(boiler.IsClear); + + // Blank field = keep the inherited value (null), same as the inline editor. + var pump = outcome.Rows[1]; + Assert.Null(pump.Connection); + Assert.Equal("ns=2;s=Pump2", pump.SourceReference); + Assert.Null(pump.Filter); + } + + [Fact] + public void AllBlankRow_IsMarkedAsClear() + { + // Every field blank = "inherit everything" — the caller clears any existing + // override row, exactly as the inline Save does when all fields are blank. + var csv = "SourceName,Connection,SourceReference,Filter\nBoilerAlarms,,,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.False(outcome.HasErrors); + var row = Assert.Single(outcome.Rows); + Assert.True(row.IsClear); + } + + [Fact] + public void SourcesAbsentFromTheFile_AreUntouched_MergeNotReplace() + { + // Merge semantics (matching the CLI): only the named source is in the batch; + // PumpAlarms/LockedAlarms keep whatever override they already have. + var csv = "SourceName,Connection,SourceReference,Filter\nBoilerAlarms,opc-b,,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.False(outcome.HasErrors); + Assert.Equal("BoilerAlarms", Assert.Single(outcome.Rows).SourceName); + } + + // ── Core: bad rows → errors, NO rows (all-or-nothing) ─────────────────── + + [Fact] + public void UnknownSource_ProducesError_AndAppliesNothing() + { + var csv = "SourceName,Connection,SourceReference,Filter\n" + + "DoesNotExist,opc-b,,\n" + + "BoilerAlarms,opc-b,,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.True(outcome.HasErrors); + Assert.Empty(outcome.Rows); // all-or-nothing: nothing applied + Assert.Contains(outcome.Errors, e => e.Contains("DoesNotExist") && e.Contains("Line 2")); + } + + [Fact] + public void TemplateLockedSource_IsRejected_LikeTheServer() + { + var csv = "SourceName,Connection,SourceReference,Filter\nLockedAlarms,opc-b,,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.True(outcome.HasErrors); + Assert.Empty(outcome.Rows); + Assert.Contains(outcome.Errors, e => e.Contains("LockedAlarms") && e.Contains("locked")); + } + + [Fact] + public void DuplicateSource_IsRejected_AndAppliesNothing() + { + var csv = "SourceName,Connection,SourceReference,Filter\n" + + "BoilerAlarms,opc-b,,\n" + + "BoilerAlarms,opc-c,,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.True(outcome.HasErrors); + Assert.Empty(outcome.Rows); + Assert.Contains(outcome.Errors, e => e.Contains("BoilerAlarms") && e.Contains("more than once")); + } + + [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\nBoilerAlarms,opc-b,,\n"; + var parsed = NativeAlarmSourceOverrideCsvParser.Parse(csv); + + var outcome = InstanceConfigure.BuildNativeAlarmSourceCsvImport(parsed, Sources()); + + Assert.True(outcome.HasErrors); + Assert.Empty(outcome.Rows); + Assert.NotEmpty(outcome.Errors); + } + + // ── Structural: InputFile + reuse-the-existing-save-path wiring ───────── + + [Fact] + public void Page_WiresNativeAlarmSourceCsvInputFile_WithTestHooks() + { + var markup = InstanceConfigureMarkup; + Assert.Contains("data-test=\"nas-csv-import-input\"", markup); + Assert.Contains("data-test=\"nas-csv-import-result\"", markup); + Assert.Contains("OnNativeAlarmSourceCsvImportSelectedAsync", markup); + // Reuses the SHARED parser — no duplicated CSV parsing in the UI. + Assert.Contains("NativeAlarmSourceOverrideCsvParser.Parse", markup); + Assert.Contains("BuildNativeAlarmSourceCsvImport", markup); + } + + [Fact] + public void Import_AppliesViaTheExistingRepositoryUpsertPath() + { + var markup = InstanceConfigureMarkup; + // The inline Save and the CSV import share one persistence helper. + Assert.Contains("UpsertNativeOverrideCore", markup); + Assert.Contains("ClearNativeOverrideCore", markup); + // Same size cap as the attribute importer. + Assert.Contains("MaxCsvImportBytes", markup); + } +}