feat(config): DraftValidator rule + DraftSnapshot.VirtualTags for Tag/VirtualTag NodeId collisions
This commit is contained in:
@@ -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
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user