From 85db4571b2d98af66289ae1b1e9a1e3d626a6168 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 16:18:08 -0400 Subject: [PATCH] feat(cli): --element-type and JSON --value for List attributes --- .../Commands/TemplateCommands.cs | 114 +++++++++++- src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 40 ++-- .../Commands/TemplateAttributeListTests.cs | 172 ++++++++++++++++++ 3 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs index 5201e225..d1491916 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs @@ -138,9 +138,10 @@ public static class TemplateCommands var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Attribute name", Required = true }; var dataTypeOption = new Option("--data-type") { Description = "Data type", Required = true }; - var valueOption = new Option("--value") { Description = "Default value" }; + var valueOption = new Option("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." }; var descOption = new Option("--description") { Description = "Description" }; var sourceOption = new Option("--data-source") { Description = "Data source reference" }; + var elementTypeOption = new Option("--element-type") { Description = ElementTypeOptionDescription }; var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; @@ -151,28 +152,39 @@ public static class TemplateCommands addCmd.Add(valueOption); addCmd.Add(descOption); addCmd.Add(sourceOption); + addCmd.Add(elementTypeOption); addCmd.Add(lockedOption); addCmd.SetAction(async (ParseResult result) => { + var dataType = result.GetValue(dataTypeOption)!; + var elementType = result.GetValue(elementTypeOption); + if (!TryValidateElementType(dataType, elementType, out var error)) + { + OutputFormatter.WriteError(error!, "INVALID_ARGUMENT"); + return 1; + } + return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new AddTemplateAttributeCommand( + BuildAddAttributeCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, - result.GetValue(dataTypeOption)!, + dataType, result.GetValue(valueOption), result.GetValue(descOption), result.GetValue(sourceOption), - result.GetValue(lockedOption))); + result.GetValue(lockedOption), + elementType)); }); group.Add(addCmd); var updateIdOption = new Option("--id") { Description = "Attribute ID", Required = true }; var updateNameOption = new Option("--name") { Description = "Attribute name", Required = true }; var updateDataTypeOption = new Option("--data-type") { Description = "Data type", Required = true }; - var updateValueOption = new Option("--value") { Description = "Default value" }; + var updateValueOption = new Option("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." }; var updateDescOption = new Option("--description") { Description = "Description" }; var updateSourceOption = new Option("--data-source") { Description = "Data source reference" }; + var updateElementTypeOption = new Option("--element-type") { Description = ElementTypeOptionDescription }; var updateLockedOption = new Option("--locked") { Description = "Lock status" }; updateLockedOption.DefaultValueFactory = _ => false; @@ -183,19 +195,29 @@ public static class TemplateCommands updateCmd.Add(updateValueOption); updateCmd.Add(updateDescOption); updateCmd.Add(updateSourceOption); + updateCmd.Add(updateElementTypeOption); updateCmd.Add(updateLockedOption); updateCmd.SetAction(async (ParseResult result) => { + var dataType = result.GetValue(updateDataTypeOption)!; + var elementType = result.GetValue(updateElementTypeOption); + if (!TryValidateElementType(dataType, elementType, out var error)) + { + OutputFormatter.WriteError(error!, "INVALID_ARGUMENT"); + return 1; + } + return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new UpdateTemplateAttributeCommand( + BuildUpdateAttributeCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, - result.GetValue(updateDataTypeOption)!, + dataType, result.GetValue(updateValueOption), result.GetValue(updateDescOption), result.GetValue(updateSourceOption), - result.GetValue(updateLockedOption))); + result.GetValue(updateLockedOption), + elementType)); }); group.Add(updateCmd); @@ -213,6 +235,82 @@ public static class TemplateCommands return group; } + /// Shared description for the --element-type option on attribute add/update. + internal const string ElementTypeOptionDescription = + "Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List."; + + /// The element scalar types permitted for a List attribute (matches the Management API). + private static readonly string[] ValidElementScalars = + { "String", "Int32", "Float", "Double", "Boolean", "DateTime" }; + + /// + /// Validates the --data-type / --element-type combination client-side so + /// the CLI fails fast with a clear message before contacting the Management API (the + /// server validates independently). A List attribute requires a valid element scalar; + /// a non-List attribute must not carry an element type. Comparison is case-insensitive. + /// + /// The raw --data-type value. + /// The raw --element-type value, or null if absent. + /// A descriptive error message when validation fails; otherwise null. + /// true when the combination is valid; otherwise false. + internal static bool TryValidateElementType(string dataType, string? elementType, out string? error) + { + error = null; + var isList = string.Equals(dataType, "List", StringComparison.OrdinalIgnoreCase); + var hasElementType = !string.IsNullOrWhiteSpace(elementType); + + if (isList) + { + if (!hasElementType) + { + error = "--element-type is required when --data-type is List " + + "(one of: String, Int32, Float, Double, Boolean, DateTime)."; + return false; + } + + if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase)) + { + error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: " + + string.Join(", ", ValidElementScalars) + "."; + return false; + } + + return true; + } + + if (hasElementType) + { + error = "--element-type is only valid when --data-type is List."; + return false; + } + + return true; + } + + /// + /// Builds the payload sent to the Management API. + /// The raw string is forwarded unchanged — for a List attribute it + /// is a JSON array, which the API/codec parses; the CLI does not reshape it. + /// + internal static AddTemplateAttributeCommand BuildAddAttributeCommand( + int templateId, string name, string dataType, string? value, + string? description, string? dataSource, bool isLocked, string? elementType) + => new(templateId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType)); + + /// + /// Builds the payload sent to the Management API. + /// The raw string is forwarded unchanged — for a List attribute it + /// is a JSON array, which the API/codec parses; the CLI does not reshape it. + /// + internal static UpdateTemplateAttributeCommand BuildUpdateAttributeCommand( + int attributeId, string name, string dataType, string? value, + string? description, string? dataSource, bool isLocked, string? elementType) + => new(attributeId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType)); + + /// Trims a non-empty element type; an empty/whitespace value becomes null (no element type). + private static string? NormalizeElementType(string? elementType) + => string.IsNullOrWhiteSpace(elementType) ? null : elementType.Trim(); + private static Command BuildAlarm(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("alarm") { Description = "Manage template alarms" }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 76706512..ce9c62a9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -164,32 +164,50 @@ scadabridge --url template validate --id Add an attribute to a template. ```sh -scadabridge --url template attribute add --template-id --name --data-type [--default-value ] [--tag-path ] +scadabridge --url template attribute add --template-id --name --data-type [--value ] [--element-type ] [--description ] [--data-source ] [--locked] ``` | Option | Required | Description | |--------|----------|-------------| | `--template-id` | yes | Template ID | | `--name` | yes | Attribute name | -| `--data-type` | yes | Attribute data type (e.g. `Float`, `Int`, `String`, `Bool`) | -| `--default-value` | no | Default value | -| `--tag-path` | no | Data connection tag path | +| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) | +| `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`); the raw string is forwarded to the API, which parses it | +| `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise | +| `--description` | no | Description | +| `--data-source` | no | Data source reference | +| `--locked` | no | Lock the attribute in derived templates | + +**List example** — add a multi-value String attribute with two default elements: + +```sh +scadabridge --url template attribute add --template-id 7 --name WorkOrders \ + --data-type List --element-type String --value '["WO-1","WO-2"]' +``` + +The CLI validates the data-type / element-type combination locally before calling the +API: `--data-type List` requires a valid `--element-type`, and `--element-type` may only +be supplied when `--data-type` is `List`. The Management API re-validates server-side. #### `template attribute update` -Update an attribute on a template. +Update an attribute on a template. An update **replaces** the whole entity — every +required field below must be supplied with its post-update value, even if unchanged. ```sh -scadabridge --url template attribute update --template-id --name [--data-type ] [--default-value ] [--tag-path ] +scadabridge --url template attribute update --id --name --data-type [--value ] [--element-type ] [--description ] [--data-source ] [--locked] ``` | Option | Required | Description | |--------|----------|-------------| -| `--template-id` | yes | Template ID | -| `--name` | yes | Attribute name to update | -| `--data-type` | no | Updated data type | -| `--default-value` | no | Updated default value | -| `--tag-path` | no | Updated tag path | +| `--id` | yes | Attribute ID | +| `--name` | yes | Attribute name | +| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) | +| `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`) | +| `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise | +| `--description` | no | Description | +| `--data-source` | no | Data source reference | +| `--locked` | no | Lock the attribute in derived templates | #### `template attribute delete` diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs new file mode 100644 index 00000000..11c6bacc --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/TemplateAttributeListTests.cs @@ -0,0 +1,172 @@ +using System.CommandLine; +using ZB.MOM.WW.ScadaBridge.CLI.Commands; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; + +/// +/// MV-11: the template attribute add / update commands must support +/// structured multi-value (List) attributes — a new --element-type option, +/// a JSON-array --value, client-side element-type validation, and +/// / +/// wired into the +/// payload sent to the Management API. +/// +public class TemplateAttributeListTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option Format = CliOptions.CreateFormatOption(); + + private static Command AttributeGroup() + => TemplateCommands.Build(Url, Format, Username, Password) + .Subcommands.Single(c => c.Name == "attribute"); + + // ---- option surface ---- + + [Fact] + public void AttributeAdd_HasElementTypeOption() + { + var add = AttributeGroup().Subcommands.Single(c => c.Name == "add"); + Assert.Contains("--element-type", add.Options.Select(o => o.Name)); + } + + [Fact] + public void AttributeUpdate_HasElementTypeOption() + { + var update = AttributeGroup().Subcommands.Single(c => c.Name == "update"); + Assert.Contains("--element-type", update.Options.Select(o => o.Name)); + } + + // ---- client-side element-type validation (both directions) ---- + + [Theory] + [InlineData("String")] + [InlineData("Int32")] + [InlineData("Float")] + [InlineData("Double")] + [InlineData("Boolean")] + [InlineData("DateTime")] + public void ValidateElementType_ListWithValidScalar_Ok(string elementType) + { + var ok = TemplateCommands.TryValidateElementType("List", elementType, out var error); + Assert.True(ok); + Assert.Null(error); + } + + [Fact] + public void ValidateElementType_ListWithValidScalar_CaseInsensitive() + { + var ok = TemplateCommands.TryValidateElementType("List", "string", out var error); + Assert.True(ok); + Assert.Null(error); + } + + [Fact] + public void ValidateElementType_ListWithoutElementType_Error() + { + var ok = TemplateCommands.TryValidateElementType("List", null, out var error); + Assert.False(ok); + Assert.NotNull(error); + } + + [Fact] + public void ValidateElementType_ListWithBlankElementType_Error() + { + var ok = TemplateCommands.TryValidateElementType("List", " ", out var error); + Assert.False(ok); + Assert.NotNull(error); + } + + [Fact] + public void ValidateElementType_ListWithInvalidScalar_Error() + { + var ok = TemplateCommands.TryValidateElementType("List", "List", out var error); + Assert.False(ok); + Assert.NotNull(error); + } + + [Fact] + public void ValidateElementType_ListWithBinaryScalar_Error() + { + // Binary is a DataType but not a permitted List element scalar. + var ok = TemplateCommands.TryValidateElementType("List", "Binary", out var error); + Assert.False(ok); + Assert.NotNull(error); + } + + [Fact] + public void ValidateElementType_ScalarWithElementType_Error() + { + var ok = TemplateCommands.TryValidateElementType("String", "Int32", out var error); + Assert.False(ok); + Assert.NotNull(error); + } + + [Fact] + public void ValidateElementType_ScalarWithoutElementType_Ok() + { + var ok = TemplateCommands.TryValidateElementType("Float", null, out var error); + Assert.True(ok); + Assert.Null(error); + } + + // ---- payload wiring: the raw JSON value + ElementDataType flow into the command ---- + + [Fact] + public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue() + { + var cmd = TemplateCommands.BuildAddAttributeCommand( + templateId: 7, + name: "WorkOrders", + dataType: "List", + value: """["WO-1","WO-2"]""", + description: null, + dataSource: null, + isLocked: false, + elementType: "String"); + + Assert.Equal(7, cmd.TemplateId); + Assert.Equal("List", cmd.DataType); + Assert.Equal("String", cmd.ElementDataType); + // The CLI forwards the raw JSON string unchanged — the API/codec parses it. + Assert.Equal("""["WO-1","WO-2"]""", cmd.Value); + } + + [Fact] + public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue() + { + var cmd = TemplateCommands.BuildUpdateAttributeCommand( + attributeId: 42, + name: "WorkOrders", + dataType: "List", + value: """["A","B"]""", + description: null, + dataSource: null, + isLocked: false, + elementType: "Int32"); + + Assert.Equal(42, cmd.AttributeId); + Assert.Equal("List", cmd.DataType); + Assert.Equal("Int32", cmd.ElementDataType); + Assert.Equal("""["A","B"]""", cmd.Value); + } + + [Fact] + public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull() + { + var cmd = TemplateCommands.BuildAddAttributeCommand( + templateId: 1, + name: "Speed", + dataType: "Float", + value: "0", + description: null, + dataSource: null, + isLocked: false, + elementType: null); + + Assert.Null(cmd.ElementDataType); + Assert.Equal("Float", cmd.DataType); + } +}