feat(central-ui): native-alarm-source CSV import on InstanceConfigure

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.
This commit is contained in:
Joseph Doherty
2026-08-01 11:11:06 -04:00
parent 697be0ce43
commit a26d6ba317
4 changed files with 497 additions and 29 deletions
+1
View File
@@ -125,6 +125,7 @@ Central cluster only. Sites have no user interface.
- **Source Reference** — the concrete native key for this instance. - **Source Reference** — the concrete native key for this instance.
- **Filter** — the per-instance condition filter. - **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. - 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. - 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. - **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. - **Enable** instances — re-activates a disabled instance.
@@ -431,14 +431,59 @@
@* Native Alarm Source Overrides *@ @* Native Alarm Source Overrides *@
<div class="card mb-3"> <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>Native Alarm Source Overrides</strong> <div>
<small class="text-muted ms-2"> <strong>Native Alarm Source Overrides</strong>
Retarget an inherited native alarm source binding for this instance. <small class="text-muted ms-2">
Leave a field blank to keep the inherited value. Retarget an inherited native alarm source binding for this instance.
</small> Leave a field blank to keep the inherited value.
</small>
</div>
@* 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)
{
<label class="btn btn-outline-secondary btn-sm mb-0 flex-shrink-0 ms-2">
Import overrides (CSV)
<InputFile OnChange="OnNativeAlarmSourceCsvImportSelectedAsync"
accept=".csv"
data-test="nas-csv-import-input"
class="d-none"
disabled="@_saving" />
</label>
}
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@if (_nasCsvImportResult is not null)
{
<div class="p-2 pb-0">
<div class="alert @(_nasCsvImportSucceeded ? "alert-success" : "alert-danger") small mb-0"
data-test="nas-csv-import-result">
@if (_nasCsvImportSucceeded)
{
<span>@_nasCsvImportResult</span>
}
else
{
<div class="fw-semibold mb-1">@_nasCsvImportResult</div>
<ul class="mb-0 ps-3">
@foreach (var err in _nasCsvImportErrors)
{
<li>@err</li>
}
</ul>
}
</div>
</div>
}
@if (_nativeSources.Count == 0) @if (_nativeSources.Count == 0)
{ {
<p class="text-muted small p-3 mb-0">No native alarm sources on this template.</p> <p class="text-muted small p-3 mb-0">No native alarm sources on this template.</p>
@@ -623,6 +668,12 @@
private Dictionary<string, string?> _nasRefEdit = new(); private Dictionary<string, string?> _nasRefEdit = new();
private Dictionary<string, string?> _nasFilterEdit = new(); private Dictionary<string, string?> _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<string> _nasCsvImportErrors = Array.Empty<string>();
// Override edit modal state — non-null while the modal is open. // Override edit modal state — non-null while the modal is open.
private TemplateAlarm? _editingAlarm; private TemplateAlarm? _editingAlarm;
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
@@ -1368,29 +1419,8 @@
return; return;
} }
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); await UpsertNativeOverrideCore(sourceName, conn, sref, filt);
if (existing == null) await TemplateEngineRepository.SaveChangesAsync();
{
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;
}
_toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'."); _toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'.");
} }
catch (Exception ex) catch (Exception ex)
@@ -1415,6 +1445,42 @@
_saving = false; _saving = false;
} }
/// <summary>
/// Upserts one native alarm source override row and re-seeds the inline editor's
/// state for that source. Does NOT call <c>SaveChangesAsync</c> — 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.
/// </summary>
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) private async Task ClearNativeOverrideCore(string sourceName)
{ {
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName); var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
@@ -1431,6 +1497,94 @@
private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); private static string? Blank(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
// ── Native alarm source CSV bulk import ─────────────────
/// <summary>
/// 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
/// <see cref="NativeAlarmSourceOverrideCsvParser"/> — the exact parser the CLI
/// <c>instance native-alarm-source import --file</c> command uses — validates every
/// row via <see cref="BuildNativeAlarmSourceCsvImport"/>, 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.
/// </summary>
private async Task OnNativeAlarmSourceCsvImportSelectedAsync(InputFileChangeEventArgs e)
{
_saving = true;
_nasCsvImportResult = null;
_nasCsvImportSucceeded = false;
_nasCsvImportErrors = Array.Empty<string>();
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<string>());
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<string>());
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<string>());
}
finally
{
_saving = false;
}
}
private void ShowNativeCsvImportFailure(string headline, IReadOnlyList<string> errors)
{
_nasCsvImportSucceeded = false;
_nasCsvImportResult = headline;
_nasCsvImportErrors = errors;
_toast.ShowError(headline);
}
/// <summary> /// <summary>
/// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum /// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum
/// to the canonical SCADA type string the AlarmTriggerEditor compares /// to the canonical SCADA type string the AlarmTriggerEditor compares
@@ -11,6 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
/// file's text, calls <see cref="OverrideCsvParser.Parse"/>, then feeds the result /// file's text, calls <see cref="OverrideCsvParser.Parse"/>, then feeds the result
/// here; on success it applies the returned dict through the SAME /// here; on success it applies the returned dict through the SAME
/// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses. /// <c>InstanceService.SetAttributeOverrideAsync</c> path the manual editor uses.
///
/// <para>The native-alarm-source CSV import (<see cref="BuildNativeAlarmSourceCsvImport"/>)
/// follows the identical shape: the Razor side parses the upload with the shared
/// <see cref="NativeAlarmSourceOverrideCsvParser"/> — the same parser the CLI
/// <c>instance native-alarm-source import --file</c> command uses — validates it here,
/// then upserts each row through the SAME repository path the inline editor's Save
/// uses.</para>
/// </summary> /// </summary>
public partial class InstanceConfigure public partial class InstanceConfigure
{ {
@@ -108,6 +115,113 @@ public partial class InstanceConfigure
return new CsvOverrideImportOutcome(overrides, Array.Empty<string>()); return new CsvOverrideImportOutcome(overrides, Array.Empty<string>());
} }
/// <summary>
/// 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.
/// </summary>
internal sealed record NativeAlarmSourceCsvImportRow(
string SourceName, string? Connection, string? SourceReference, string? Filter)
{
/// <summary>True when all three override fields are blank (row means "inherit everything").</summary>
public bool IsClear => Connection is null && SourceReference is null && Filter is null;
}
/// <summary>
/// Outcome of validating a parsed native-alarm-source-override CSV against an
/// instance's template source bindings. <see cref="Rows"/> is the ordered list of
/// retargets 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 NativeAlarmSourceCsvImportOutcome(
IReadOnlyList<NativeAlarmSourceCsvImportRow> Rows,
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 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.
///
/// <para>Rules — the same batch rules the CLI's
/// <c>instance native-alarm-source import --file</c> path enforces server-side in
/// <c>ManagementActor.HandleSetInstanceNativeAlarmSourceOverrides</c>:</para>
/// <list type="bullet">
/// <item>The source name must resolve for this instance — an unknown name would
/// create a dead override the flattener silently drops.</item>
/// <item>The source must not be template-locked.</item>
/// <item>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.</item>
/// </list>
///
/// <para>Semantics are <b>merge, not full replace</b> (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.</para>
///
/// <para>All-or-nothing: if ANY parser or validation error is present, the returned
/// <see cref="NativeAlarmSourceCsvImportOutcome.Rows"/> is empty so the caller
/// applies nothing and surfaces the full error list.</para>
/// </summary>
/// <param name="parsed">The result of <see cref="NativeAlarmSourceOverrideCsvParser.Parse"/>.</param>
/// <param name="templateSources">The template's native alarm source bindings (the page's <c>_nativeSources</c>).</param>
/// <returns>The validated retarget outcome; <see cref="NativeAlarmSourceCsvImportOutcome.Rows"/>
/// is empty and errors are populated when any row fails validation.</returns>
internal static NativeAlarmSourceCsvImportOutcome BuildNativeAlarmSourceCsvImport(
NativeAlarmSourceOverrideCsvParseResult parsed,
IReadOnlyList<TemplateNativeAlarmSource> templateSources)
{
var errors = new List<string>(parsed.Errors);
var byName = templateSources.ToDictionary(s => s.Name, StringComparer.Ordinal);
var rows = new List<NativeAlarmSourceCsvImportRow>();
var seen = new HashSet<string>(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<NativeAlarmSourceCsvImportRow>(), errors);
return new NativeAlarmSourceCsvImportOutcome(rows, Array.Empty<string>());
}
/// <summary> /// <summary>
/// Validates a single non-null override value against the attribute's declared /// Validates a single non-null override value against the attribute's declared
/// type using <see cref="AttributeValueCodec"/>. Returns a line-qualified error /// type using <see cref="AttributeValueCodec"/>. Returns a line-qualified error
@@ -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;
/// <summary>
/// The Instance Configure page's Native Alarm Source Overrides card accepts a CSV of
/// per-instance retargets via an <c>&lt;InputFile&gt;</c> — the UI half of the CLI's
/// <c>instance native-alarm-source import --file</c>. The upload is parsed with the
/// SHARED <see cref="NativeAlarmSourceOverrideCsvParser"/> (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.
///
/// <para>
/// <c>InstanceConfigure</c> 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
/// <c>internal static</c> helper exercised directly here, plus structural assertions
/// over the component source that pin the InputFile + reuse-the-existing-save-path
/// wiring.
/// </para>
/// </summary>
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<TemplateNativeAlarmSource> 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);
}
}