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; } = [];
|
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
|
||||||
/// <summary>Gets the list of tags.</summary>
|
/// <summary>Gets the list of tags.</summary>
|
||||||
public IReadOnlyList<Tag> Tags { get; init; } = [];
|
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>
|
/// <summary>Gets the list of poll groups.</summary>
|
||||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,34 @@ public static class DraftValidator
|
|||||||
ValidateReservationPreflight(draft, errors);
|
ValidateReservationPreflight(draft, errors);
|
||||||
ValidateEquipmentIdDerivation(draft, errors);
|
ValidateEquipmentIdDerivation(draft, errors);
|
||||||
ValidateDriverNamespaceCompatibility(draft, errors);
|
ValidateDriverNamespaceCompatibility(draft, errors);
|
||||||
|
ValidateNoEquipmentSignalNameCollision(draft, errors);
|
||||||
|
|
||||||
return 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) =>
|
private static bool IsValidSegment(string? s) =>
|
||||||
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,76 @@ public sealed class DraftValidatorTests
|
|||||||
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
|
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
|
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user