From 872ce2b565242db75f233785ba81017d726cbf97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 15:38:18 -0400 Subject: [PATCH] feat(validation): semantic checks for List attributes (element type, default value, trigger operands) --- .../Validation/SemanticValidator.cs | 78 +++++++ .../Validation/ValidationService.cs | 5 +- .../Validation/SemanticValidatorTests.cs | 213 ++++++++++++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs index adc54210..3e2849b2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; @@ -48,6 +50,11 @@ public class SemanticValidator attributeMap.TryAdd(a.CanonicalName, a); } + // List-attribute type semantics (MV-5): element-type cardinality + default + // value parseability. Trigger-operand rejection (rule 3) is handled below + // by the existing NumericDataTypes guard (List is never numeric). + ValidateListAttributes(configuration, errors); + // Collect alarm on-trigger script names for cross-call violation checks var alarmOnTriggerScripts = new HashSet(StringComparer.Ordinal); foreach (var alarm in configuration.Alarms) @@ -250,6 +257,77 @@ public class SemanticValidator return new ValidationResult { Errors = errors, Warnings = warnings }; } + /// + /// MV-5 — semantic validation of List-attribute type configuration. Two rules: + /// + /// Element-type cardinality. A attribute + /// must carry a non-empty that is + /// a valid element scalar (see ); + /// a non-List attribute must NOT carry an element type. + /// Default-value parseability. A non-empty authored default + /// on a List attribute must + /// without throwing. + /// + /// Attributes whose doesn't parse to a + /// known are skipped here (their data type is not "List", + /// so only the "no element type" half could apply, and an unparseable type is a + /// separate concern not introduced by this feature). + /// + private static void ValidateListAttributes( + FlattenedConfiguration configuration, + List errors) + { + foreach (var attr in configuration.Attributes) + { + var isList = string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase); + var hasElementType = !string.IsNullOrWhiteSpace(attr.ElementDataType); + + // ── Rule 1: element-type cardinality ───────────────────────────── + if (!isList) + { + if (hasElementType) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"Attribute '{attr.CanonicalName}' has data type '{attr.DataType}' but declares an element type '{attr.ElementDataType}'; element types are only valid on List attributes.", + attr.CanonicalName)); + } + continue; // Non-List attributes have no list-specific value to check. + } + + if (!hasElementType) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"List attribute '{attr.CanonicalName}' must declare an element type (one of String, Int32, Float, Double, Boolean, DateTime).", + attr.CanonicalName)); + continue; // Without an element type we can't validate the default value. + } + + if (!Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var elementType) + || !AttributeValueCodec.IsValidElementType(elementType)) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"List attribute '{attr.CanonicalName}' has element type '{attr.ElementDataType}', which is not a valid element scalar (one of String, Int32, Float, Double, Boolean, DateTime).", + attr.CanonicalName)); + continue; // A bad element type makes the default-value check meaningless. + } + + // ── Rule 2: default-value parseability ─────────────────────────── + if (!string.IsNullOrEmpty(attr.Value)) + { + try + { + AttributeValueCodec.Decode(attr.Value, DataType.List, elementType); + } + catch (FormatException ex) + { + errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata, + $"List attribute '{attr.CanonicalName}' has a default value that is not a valid list of '{elementType}': {ex.Message}", + attr.CanonicalName)); + } + } + } + } + private static void ValidateCallParameters( string callerName, CallTarget call, diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs index 2dd21b01..f99517cb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs @@ -18,7 +18,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; /// and (on the deploy path) the bound connection must exist on the target site. /// Severity is context-dependent: a non-blocking Warning at template design time /// (bindings are set later) and a deploy-gating Error when enforced (M2.8 / #23). -/// 8. Does NOT verify tag path resolution on devices +/// 8. List-attribute type semantics (MV-5, via ): +/// element-type cardinality, default-value parseability, and trigger-operand +/// rejection for List attributes. +/// 9. Does NOT verify tag path resolution on devices /// public class ValidationService { diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs index 672090bb..07d95be7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs @@ -1056,4 +1056,217 @@ public class SemanticValidatorTests var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } + + // ── MV-5 List attribute semantics ──────────────────────────────────────── + + [Fact] + public void Validate_ListAttribute_ValidElementTypeAndDefault_NoError() + { + // List + String element + a well-formed JSON-array default — fully valid. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Tags", + DataType = "List", + ElementDataType = "String", + Value = "[\"a\",\"b\"]" + } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata); + } + + [Fact] + public void Validate_ListAttribute_NullElementType_ReturnsError() + { + // List with no element type — rule 1 violation (cardinality). + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = null } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Tags" && + e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ScalarAttribute_WithElementType_ReturnsError() + { + // A non-List attribute must not carry an element type — rule 1 violation. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Status", DataType = "String", ElementDataType = "String" } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Status" && + e.Message.Contains("element type", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ListAttribute_NonScalarElementType_ReturnsError() + { + // Binary and List are not valid List element scalars — rule 1 violation. + foreach (var bad in new[] { "Binary", "List" }) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = bad } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Tags"); + } + } + + [Fact] + public void Validate_ListAttribute_MalformedJsonDefault_ReturnsError() + { + // Unterminated JSON array default — rule 2 (default parseability) violation. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Tags", + DataType = "List", + ElementDataType = "String", + Value = "[\"a\"" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Tags" && + e.Message.Contains("default", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ListAttribute_DefaultElementNotParseable_ReturnsError() + { + // Element type Int32 but the default carries a non-integer element — rule 2 violation. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Counts", + DataType = "List", + ElementDataType = "Int32", + Value = "[\"x\"]" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.MissingMetadata && + e.EntityName == "Counts" && + e.Message.Contains("Int32")); + } + + [Fact] + public void Validate_ListAttribute_EmptyDefault_NoDefaultError() + { + // No authored default → rule 2 is inert; only the (satisfied) cardinality rule applies. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "String", Value = "" } + ] + }; + + var result = _sut.Validate(config); + Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.MissingMetadata); + } + + [Fact] + public void Validate_ListAttribute_AsRangeViolationOperand_ReturnsError() + { + // Rule 3 confirmation: a List attribute is non-numeric, so a RangeViolation + // trigger over it is rejected by the existing NumericDataTypes guard. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Int32" } + ], + Alarms = + [ + new ResolvedAlarm + { + CanonicalName = "BadAlarm", + TriggerType = "RangeViolation", + TriggerConfiguration = "{\"attributeName\":\"Tags\"}" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.TriggerOperandType && + e.Message.Contains("non-numeric")); + } + + [Fact] + public void Validate_ListAttribute_AsHiLoOperand_ReturnsError() + { + // Rule 3 confirmation for HiLo. + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Instance1", + Attributes = + [ + new ResolvedAttribute { CanonicalName = "Tags", DataType = "List", ElementDataType = "Double" } + ], + Alarms = + [ + new ResolvedAlarm + { + CanonicalName = "BadHiLo", + TriggerType = "HiLo", + TriggerConfiguration = "{\"attributeName\":\"Tags\",\"hi\":80}" + } + ] + }; + + var result = _sut.Validate(config); + Assert.Contains(result.Errors, e => + e.Category == ValidationCategory.TriggerOperandType && + e.Message.Contains("non-numeric")); + } }