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("