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:
Joseph Doherty
2026-07-16 06:01:10 -04:00
parent 6dc5af7aa2
commit b68c313372
4 changed files with 396 additions and 0 deletions
@@ -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)
// ------------------------------------------------------------------------------------