feat(validation): semantic checks for List attributes (element type, default value, trigger operands)

This commit is contained in:
Joseph Doherty
2026-06-16 15:38:18 -04:00
parent a1d464b50d
commit 872ce2b565
3 changed files with 295 additions and 1 deletions
@@ -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<string>(StringComparer.Ordinal);
foreach (var alarm in configuration.Alarms)
@@ -250,6 +257,77 @@ public class SemanticValidator
return new ValidationResult { Errors = errors, Warnings = warnings };
}
/// <summary>
/// MV-5 — semantic validation of List-attribute type configuration. Two rules:
/// <list type="number">
/// <item><b>Element-type cardinality.</b> A <see cref="DataType.List"/> attribute
/// must carry a non-empty <see cref="ResolvedAttribute.ElementDataType"/> that is
/// a valid element scalar (see <see cref="AttributeValueCodec.IsValidElementType"/>);
/// a non-List attribute must NOT carry an element type.</item>
/// <item><b>Default-value parseability.</b> A non-empty authored default
/// <see cref="ResolvedAttribute.Value"/> on a List attribute must
/// <see cref="AttributeValueCodec.Decode"/> without throwing.</item>
/// </list>
/// Attributes whose <see cref="ResolvedAttribute.DataType"/> doesn't parse to a
/// known <see cref="DataType"/> 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).
/// </summary>
private static void ValidateListAttributes(
FlattenedConfiguration configuration,
List<ValidationEntry> 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<DataType>(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,
@@ -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 <see cref="SemanticValidator"/>):
/// element-type cardinality, default-value parseability, and trigger-operand
/// rejection for List attributes.
/// 9. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
@@ -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"));
}
}