feat(validation): semantic checks for List attributes (element type, default value, trigger operands)
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
+213
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user