feat(config): DraftValidator rule + DraftSnapshot.VirtualTags for Tag/VirtualTag NodeId collisions

This commit is contained in:
Joseph Doherty
2026-06-07 10:33:45 -04:00
parent 6b36eff2d3
commit 83c7149be0
3 changed files with 98 additions and 0 deletions
@@ -39,6 +39,10 @@ public sealed class DraftSnapshot
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
/// <summary>Gets the list of tags.</summary>
public IReadOnlyList<Tag> Tags { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space
/// with Tags; the collision rule checks both.</summary>
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
@@ -32,10 +32,34 @@ public static class DraftValidator
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
ValidateDriverNamespaceCompatibility(draft, errors);
ValidateNoEquipmentSignalNameCollision(draft, errors);
return errors;
}
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
{
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
// VirtualTag share this space with no DB cross-table uniqueness, so the same key from both collides.
static string Key(string eq, string? folder, string name) =>
string.IsNullOrWhiteSpace(folder) ? $"{eq}/{name}" : $"{eq}/{folder}/{name}";
var signals = draft.Tags
.Where(t => t.EquipmentId is not null)
.Select(t => (Key: Key(t.EquipmentId!, t.FolderPath, t.Name), Eq: t.EquipmentId!, t.Name))
.Concat(draft.VirtualTags
.Select(v => (Key: Key(v.EquipmentId, null, v.Name), Eq: v.EquipmentId, v.Name)));
foreach (var g in signals.GroupBy(s => s.Key, StringComparer.Ordinal).Where(g => g.Count() > 1))
{
var f = g.First();
errors.Add(new("EquipmentSignalNameCollision",
$"{g.Count()} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
"a Name must be unique across Tag and VirtualTag within an equipment+folder",
f.Eq));
}
}
private static bool IsValidSegment(string? s) =>
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);