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); } }