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);
@@ -186,6 +186,76 @@ public sealed class DraftValidatorTests
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
}
// ------------------------------------------------------------------------------------
// ValidateNoEquipmentSignalNameCollision — Tag/VirtualTag NodeId collision
// ------------------------------------------------------------------------------------
/// <summary>Verifies that an equipment-bound Tag and a VirtualTag sharing the same
/// equipment+name collide on a single OPC UA NodeId.</summary>
[Fact]
public void Tag_and_VirtualTag_same_equipment_and_name_collide()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: null)],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentSignalNameCollision");
}
/// <summary>Verifies that a Tag in a sub-folder does not collide with a VirtualTag at the
/// equipment root even when the names match — the NodeId keys differ.</summary>
[Fact]
public void Same_name_different_folder_does_not_collide()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: "metrics")],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision");
}
/// <summary>Verifies that a SystemPlatform Tag (EquipmentId == null) is excluded from the
/// equipment-signal node space and so never collides with an equipment VirtualTag.</summary>
[Fact]
public void SystemPlatform_tag_sharing_name_with_equipment_vtag_excluded()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(equipmentId: null, name: "speed", folderPath: null)],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision");
}
private static Tag BuildTag(string? equipmentId, string name, string? folderPath) => new()
{
TagId = $"tag-{name}",
DriverInstanceId = "d",
EquipmentId = equipmentId,
Name = name,
FolderPath = folderPath,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new()
{
VirtualTagId = $"vtag-{name}",
EquipmentId = equipmentId,
Name = name,
DataType = "Float",
ScriptId = "s-1",
};
// ------------------------------------------------------------------------------------
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
// ------------------------------------------------------------------------------------