feat(uns-v3): implement EffectiveNameGuard authoring-time uniqueness guard (B3-WP2)
Implements IEffectiveNameGuard (the Wave-A contract): per-equipment effective-name uniqueness across UnsTagReferences (DisplayNameOverride else backing raw tag Name), VirtualTags (Name), and ScriptedAlarms (Name). Ordinal comparison, self-row exclusion by (kind, id), one pooled context per CheckAsync call. Registered Scoped in EndpointRouteBuilderExtensions so WP1's UnsTreeService consumer goes live. Verified the deploy-time DraftValidator UnsEffectiveNameCollision rule already computes reference effective names from the backing raw tag's current Name (catches rename-induced collisions), spans all three sources, compares ordinal, and names both colliding sources + the equipment — no hardening needed. DraftSnapshotFactory already loads Tags/UnsTagReferences/VirtualTags/ScriptedAlarms. Tests: 8 guard tests (in-memory EF) + 3 deploy-gate tests (rename-induced collision, override-vs-VT, ordinal case-sensitivity). All green. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -205,6 +205,71 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
/// <summary>The rename-induced case the authoring guard never sees: an authoring-clean draft where
|
||||
/// a reference (no override, effective name = backing raw tag's current Name) ends up sharing an
|
||||
/// effective name with a VirtualTag after a raw-tag rename. The deploy gate is the backstop.</summary>
|
||||
[Fact]
|
||||
public void Reference_effective_from_raw_name_collides_with_virtualtag_after_rename()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "speed")], // raw tag renamed to "speed"
|
||||
UnsTagReferences =
|
||||
[
|
||||
// No override → effective name is the backing raw tag's Name ("speed").
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
errors.ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
// Names both colliding sources + the equipment.
|
||||
var msg = errors.First(e => e.Code == "UnsEffectiveNameCollision").Message;
|
||||
msg.ShouldContain("ref-1");
|
||||
msg.ShouldContain("vtag-eq-1-speed");
|
||||
msg.ShouldContain("eq-1");
|
||||
}
|
||||
|
||||
/// <summary>A reference's DisplayNameOverride (not its backing raw name) is the effective name and
|
||||
/// must be unique against a VirtualTag in the same equipment.</summary>
|
||||
[Fact]
|
||||
public void Reference_override_collides_with_virtualtag()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "raw-name")], // backing raw name differs
|
||||
UnsTagReferences =
|
||||
[
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: "computed"),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "computed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
/// <summary>Effective-name comparison is ordinal: "Speed" (VirtualTag) and "speed" (reference's
|
||||
/// backing raw name) do NOT collide — they are distinct OPC UA browse names.</summary>
|
||||
[Fact]
|
||||
public void Effective_name_collision_is_ordinal_case_sensitive()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "speed")],
|
||||
UnsTagReferences =
|
||||
[
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "Speed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
|
||||
{
|
||||
VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}",
|
||||
@@ -214,6 +279,24 @@ public sealed class DraftValidatorTests
|
||||
ScriptId = "s-1",
|
||||
};
|
||||
|
||||
private static Tag BuildTag(string tagId, string name) => new()
|
||||
{
|
||||
TagId = tagId,
|
||||
DeviceId = "dev-1",
|
||||
Name = name,
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static UnsTagReference BuildReference(string refId, string equipmentId, string tagId, string? overrideName) => new()
|
||||
{
|
||||
UnsTagReferenceId = refId,
|
||||
EquipmentId = equipmentId,
|
||||
TagId = tagId,
|
||||
DisplayNameOverride = overrideName,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3)
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user