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
@@ -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"));
}
}