From 586d54359ce3a08d507e7916c2191bd95973716d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 02:27:40 -0400 Subject: [PATCH] feat(cli): instance import-overrides --file (T16) --- .../Commands/InstanceCommands.cs | 42 +++++++++ src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 24 +++++ .../ImportOverridesTests.cs | 91 +++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs index 8dcdb641..f8df02d2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.CommandLine.Parsing; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; @@ -23,6 +24,7 @@ public static class InstanceCommands command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildImportOverrides(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildNativeAlarmSourceOverride(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption)); @@ -296,6 +298,46 @@ public static class InstanceCommands return cmd; } + private static Command BuildImportOverrides(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Instance ID", Required = true }; + var fileOption = new Option("--file") { Description = "Path to the override CSV file (columns: AttributeName,Value,ElementType)", Required = true }; + + var cmd = new Command("import-overrides") { Description = "Apply attribute overrides from a CSV file" }; + cmd.Add(idOption); + cmd.Add(fileOption); + cmd.SetAction(async (ParseResult result) => + { + var id = result.GetValue(idOption); + var filePath = result.GetValue(fileOption)!; + + string csvText; + try + { + csvText = File.ReadAllText(filePath); + } + catch (Exception ex) + { + OutputFormatter.WriteError($"Cannot read file '{filePath}': {ex.Message}", "FILE_READ_ERROR"); + return 1; + } + + var parseResult = OverrideCsvParser.Parse(csvText); + if (parseResult.Errors.Count > 0) + { + foreach (var error in parseResult.Errors) + OutputFormatter.WriteError(error, "INVALID_CSV"); + return 1; + } + + var overrides = parseResult.Rows.ToDictionary(r => r.AttributeName, r => r.Value); + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new SetInstanceOverridesCommand(id, overrides)); + }); + return cmd; + } + private static Command BuildAlarmOverride(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("alarm-override") { Description = "Manage per-instance alarm overrides" }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 8c99cbf3..8fa85064 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -539,6 +539,30 @@ scadabridge --url instance set-overrides --id --overrides | `--id` | yes | Instance ID | | `--overrides` | yes | JSON object of attribute name to value (e.g. `{"Speed": "100", "Mode": null}`); null clears an override | +#### `instance import-overrides` + +Apply attribute value overrides from a CSV file. The CSV must have a header row (`AttributeName,Value,ElementType`; the `ElementType` column is optional). An empty `Value` cell clears the override for that attribute. If the file contains any parse errors, all errors are printed and no overrides are applied. + +```sh +scadabridge --url instance import-overrides --id --file +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--id` | yes | Instance ID | +| `--file` | yes | Path to the override CSV file | + +CSV format example: + +```csv +AttributeName,Value,ElementType +Speed,100, +Mode,, +Label,"Hello, World", +``` + +(`Value` empty = clear the override for that attribute.) + #### `instance alarm-override set` Set (upsert) an alarm override on an instance. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs new file mode 100644 index 00000000..5f939db7 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ImportOverridesTests.cs @@ -0,0 +1,91 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; + +/// +/// Verifies the CSV parsing behaviour that backs instance import-overrides: +/// parse errors block the apply step; valid CSV produces the expected +/// attributeName → value dictionary that +/// consumes. +/// +public class ImportOverridesTests +{ + // ── error path ─────────────────────────────────────────────────────────── + + [Fact] + public void CsvWithMissingHeader_ProducesErrors() + { + var result = OverrideCsvParser.Parse("Speed,100"); + + Assert.NotEmpty(result.Errors); + Assert.Empty(result.Rows); + } + + [Fact] + public void CsvWithBlankAttributeName_ProducesError() + { + const string csv = "AttributeName,Value\n,100"; + var result = OverrideCsvParser.Parse(csv); + + Assert.NotEmpty(result.Errors); + // The bad row is excluded; no rows flow through. + Assert.Empty(result.Rows); + } + + [Fact] + public void CsvWithWrongColumnCount_ProducesError() + { + const string csv = "AttributeName,Value\nSpeed,100,extra"; + var result = OverrideCsvParser.Parse(csv); + + Assert.NotEmpty(result.Errors); + Assert.Empty(result.Rows); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public void ValidCsv_ProducesCorrectOverridesDictionary() + { + const string csv = """ + AttributeName,Value + Speed,100 + Mode, + Label,Hello + """; + + var parseResult = OverrideCsvParser.Parse(csv); + + Assert.Empty(parseResult.Errors); + Assert.Equal(3, parseResult.Rows.Count); + + // Build the same dictionary that BuildImportOverrides produces. + var overrides = parseResult.Rows.ToDictionary(r => r.AttributeName, r => r.Value); + + Assert.Equal("100", overrides["Speed"]); + Assert.Null(overrides["Mode"]); // empty Value → null → clear the override + Assert.Equal("Hello", overrides["Label"]); + } + + [Fact] + public void ValidCsvWithElementTypeColumn_OverridesDictIgnoresElementType() + { + const string csv = """ + AttributeName,Value,ElementType + Speed,100,Float + Mode,, + """; + + var parseResult = OverrideCsvParser.Parse(csv); + + Assert.Empty(parseResult.Errors); + Assert.Equal(2, parseResult.Rows.Count); + + // ElementType is carried on the row but SetInstanceOverridesCommand only + // takes attributeName → value; confirm the dict shape matches. + var overrides = parseResult.Rows.ToDictionary(r => r.AttributeName, r => r.Value); + + Assert.Equal("100", overrides["Speed"]); + Assert.Null(overrides["Mode"]); + } +}