Three layers were each blind to nested composition in different ways: - FlatteningPipeline only loaded compositions for templates in the parent's inheritance chain, so depth-2 composed attributes (e.g. Pump.AlarmSensor.SensorReading) never materialized. Walk composed chains breadth-first so the flattener's nested step has the data it needs. - InstanceConfigure's alarm trigger picker was fed only direct, non-locked attributes, hiding inherited and composed-member paths. Feed it the full flattened attribute list via FlatteningPipeline. - ValidationService.ExtractAttributeNameFromTriggerConfig only recognized "attributeName", silently passing alarms still using the legacy "attribute" key. Accept both keys, matching FlatteningService, AlarmActor, and AlarmTriggerConfigCodec.
287 lines
11 KiB
C#
287 lines
11 KiB
C#
using System.Text.Json;
|
|
using ScadaLink.Commons.Types.Flattening;
|
|
|
|
namespace ScadaLink.TemplateEngine.Validation;
|
|
|
|
/// <summary>
|
|
/// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness
|
|
/// before deployment. Also available on-demand (same logic, no deployment trigger).
|
|
///
|
|
/// Validation checks:
|
|
/// 1. Flattening success (no empty configuration)
|
|
/// 2. No naming collisions
|
|
/// 3. Script compilation (via ScriptCompiler)
|
|
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
|
|
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
|
|
/// 6. Connection binding completeness (all data-sourced attributes must have a binding)
|
|
/// 7. Does NOT verify tag path resolution on devices
|
|
/// </summary>
|
|
public class ValidationService
|
|
{
|
|
private readonly SemanticValidator _semanticValidator;
|
|
private readonly ScriptCompiler _scriptCompiler;
|
|
|
|
public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
|
|
{
|
|
_semanticValidator = semanticValidator;
|
|
_scriptCompiler = scriptCompiler;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience constructor that creates default dependencies.
|
|
/// </summary>
|
|
public ValidationService() : this(new SemanticValidator(), new ScriptCompiler())
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the full validation pipeline on a flattened configuration.
|
|
/// </summary>
|
|
public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList<ResolvedScript>? sharedScripts = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(configuration);
|
|
|
|
var results = new List<ValidationResult>
|
|
{
|
|
ValidateFlatteningSuccess(configuration),
|
|
ValidateNamingCollisions(configuration),
|
|
ValidateScriptCompilation(configuration),
|
|
ValidateAlarmTriggerReferences(configuration),
|
|
ValidateScriptTriggerReferences(configuration),
|
|
ValidateConnectionBindingCompleteness(configuration),
|
|
_semanticValidator.Validate(configuration, sharedScripts)
|
|
};
|
|
|
|
return ValidationResult.Merge(results.ToArray());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that flattening produced a non-empty configuration.
|
|
/// </summary>
|
|
public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
|
|
if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName))
|
|
errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure,
|
|
"Instance unique name is missing."));
|
|
|
|
if (configuration.Attributes.Count == 0 &&
|
|
configuration.Alarms.Count == 0 &&
|
|
configuration.Scripts.Count == 0)
|
|
{
|
|
return new ValidationResult
|
|
{
|
|
Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure,
|
|
"Flattened configuration contains no attributes, alarms, or scripts.")]
|
|
};
|
|
}
|
|
|
|
return errors.Count > 0
|
|
? new ValidationResult { Errors = errors }
|
|
: ValidationResult.Success();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that there are no naming collisions across entity types.
|
|
/// Canonical names must be unique within their entity type (attributes, alarms, scripts).
|
|
/// </summary>
|
|
public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
|
|
CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors);
|
|
CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors);
|
|
CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors);
|
|
|
|
return errors.Count > 0
|
|
? new ValidationResult { Errors = errors }
|
|
: ValidationResult.Success();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that all scripts compile successfully using the ScriptCompiler.
|
|
/// </summary>
|
|
public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var warnings = new List<ValidationEntry>();
|
|
|
|
foreach (var script in configuration.Scripts)
|
|
{
|
|
var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName);
|
|
if (!result.IsSuccess)
|
|
{
|
|
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
|
|
$"Script '{script.CanonicalName}' failed compilation: {result.Error}",
|
|
script.CanonicalName));
|
|
}
|
|
}
|
|
|
|
return new ValidationResult { Errors = errors, Warnings = warnings };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that alarm trigger configurations reference existing attributes.
|
|
/// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name.
|
|
/// </summary>
|
|
public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var attributeNames = new HashSet<string>(
|
|
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
|
|
|
|
foreach (var alarm in configuration.Alarms)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
|
|
continue;
|
|
|
|
var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
|
|
if (attrName != null && !attributeNames.Contains(attrName))
|
|
{
|
|
errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference,
|
|
$"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.",
|
|
alarm.CanonicalName));
|
|
}
|
|
}
|
|
|
|
return errors.Count > 0
|
|
? new ValidationResult { Errors = errors }
|
|
: ValidationResult.Success();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that script trigger configurations reference existing attributes.
|
|
/// </summary>
|
|
public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var attributeNames = new HashSet<string>(
|
|
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
|
|
|
|
foreach (var script in configuration.Scripts)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(script.TriggerConfiguration))
|
|
continue;
|
|
|
|
var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration);
|
|
if (attrName != null && !attributeNames.Contains(attrName))
|
|
{
|
|
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference,
|
|
$"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
|
|
script.CanonicalName));
|
|
}
|
|
}
|
|
|
|
return errors.Count > 0
|
|
? new ValidationResult { Errors = errors }
|
|
: ValidationResult.Success();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that all data-sourced attributes have connection bindings.
|
|
/// </summary>
|
|
public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration)
|
|
{
|
|
var errors = new List<ValidationEntry>();
|
|
var warnings = new List<ValidationEntry>();
|
|
|
|
foreach (var attr in configuration.Attributes)
|
|
{
|
|
if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null)
|
|
{
|
|
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
|
|
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
|
|
attr.CanonicalName));
|
|
}
|
|
}
|
|
|
|
return new ValidationResult { Errors = errors, Warnings = warnings };
|
|
}
|
|
|
|
private static void CheckDuplicates<T>(
|
|
IReadOnlyList<T> items,
|
|
Func<T, string> getName,
|
|
string entityType,
|
|
List<ValidationEntry> errors)
|
|
{
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var item in items)
|
|
{
|
|
var name = getName(item);
|
|
if (!seen.Add(name))
|
|
{
|
|
errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision,
|
|
$"{entityType} naming collision: '{name}' appears more than once.",
|
|
name));
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson)
|
|
{
|
|
// Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute,
|
|
// AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use
|
|
// "attribute"; the UI codec writes the canonical "attributeName".
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(triggerConfigJson);
|
|
if (doc.RootElement.TryGetProperty("attributeName", out var prop))
|
|
return prop.GetString();
|
|
if (doc.RootElement.TryGetProperty("attribute", out var legacyProp))
|
|
return legacyProp.GetString();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Not valid JSON, ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the four HiLo setpoints from a trigger configuration JSON.
|
|
/// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
|
|
/// all-nulls on malformed JSON — callers should treat that as "nothing to
|
|
/// validate" and let other checks surface the deeper problem.
|
|
/// </summary>
|
|
internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(triggerConfigJson);
|
|
var root = doc.RootElement;
|
|
return new HiLoSetpoints(
|
|
LoLo: ReadDouble(root, "loLo"),
|
|
Lo: ReadDouble(root, "lo"),
|
|
Hi: ReadDouble(root, "hi"),
|
|
HiHi: ReadDouble(root, "hiHi"),
|
|
LoLoDeadband: ReadDouble(root, "loLoDeadband"),
|
|
LoDeadband: ReadDouble(root, "loDeadband"),
|
|
HiDeadband: ReadDouble(root, "hiDeadband"),
|
|
HiHiDeadband: ReadDouble(root, "hiHiDeadband"));
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new HiLoSetpoints(null, null, null, null, null, null, null, null);
|
|
}
|
|
}
|
|
|
|
private static double? ReadDouble(JsonElement el, string name)
|
|
{
|
|
if (!el.TryGetProperty(name, out var p)) return null;
|
|
return p.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => p.GetDouble(),
|
|
JsonValueKind.String when double.TryParse(p.GetString(),
|
|
System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
|
|
_ => null
|
|
};
|
|
}
|
|
}
|
|
|
|
internal readonly record struct HiLoSetpoints(
|
|
double? LoLo, double? Lo, double? Hi, double? HiHi,
|
|
double? LoLoDeadband = null, double? LoDeadband = null,
|
|
double? HiDeadband = null, double? HiHiDeadband = null);
|