Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EffectiveNameGuardTests.cs
T
Joseph Doherty b68c313372 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
2026-07-16 06:01:10 -04:00

204 lines
7.3 KiB
C#

using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Unit tests for <see cref="EffectiveNameGuard"/> — the authoring-time UNS effective-name
/// uniqueness guard. Backed by an InMemory EF database seeded with one equipment carrying a
/// reference (override + backing raw tag), a VirtualTag, and a ScriptedAlarm.
/// </summary>
public sealed class EffectiveNameGuardTests
{
private const string EquipmentId = "EQ-000000000001";
// Reference REF-1 → raw tag TAG-1 (Name "speed"), NO override → effective "speed".
// Reference REF-2 → raw tag TAG-2 (Name "raw-temp"), override "temperature" → effective "temperature".
// VirtualTag VT-1 (Name "computed"). ScriptedAlarm AL-1 (Name "highpress").
private const string RefNoOverrideId = "REF-1";
private const string RefWithOverrideId = "REF-2";
private const string VirtualTagId = "VT-1";
private const string ScriptedAlarmId = "AL-1";
private static IDbContextFactory<OtOpcUaConfigDbContext> SeedFactory()
{
var name = $"engname-{Guid.NewGuid():N}";
var factory = new NamedFactory(name);
using var db = factory.CreateDbContext();
db.Tags.Add(new Tag
{
TagId = "TAG-1",
DeviceId = "DEV-1",
Name = "speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.Tags.Add(new Tag
{
TagId = "TAG-2",
DeviceId = "DEV-1",
Name = "raw-temp",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = RefNoOverrideId,
EquipmentId = EquipmentId,
TagId = "TAG-1",
DisplayNameOverride = null, // effective = raw tag Name "speed"
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = RefWithOverrideId,
EquipmentId = EquipmentId,
TagId = "TAG-2",
DisplayNameOverride = "temperature", // effective = "temperature"
});
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = VirtualTagId,
EquipmentId = EquipmentId,
Name = "computed",
DataType = "Float",
ScriptId = "SCR-1",
});
db.ScriptedAlarms.Add(new ScriptedAlarm
{
ScriptedAlarmId = ScriptedAlarmId,
EquipmentId = EquipmentId,
Name = "highpress",
AlarmType = "AlarmCondition",
MessageTemplate = "{TagPath} high",
PredicateScriptId = "SCR-2",
});
db.SaveChanges();
return factory;
}
private static EffectiveNameGuard Guard(IDbContextFactory<OtOpcUaConfigDbContext> factory) => new(factory);
[Fact]
public async Task Free_name_returns_null()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckAsync(
EquipmentId, "brand-new-name", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
[Fact]
public async Task Reference_vs_new_virtualtag_clash_returns_error_naming_the_reference()
{
var guard = Guard(SeedFactory());
// A new VirtualTag proposing "speed" collides with reference REF-1's effective name.
var result = await guard.CheckAsync(
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Override_effective_name_vs_new_reference_clash_returns_error()
{
var guard = Guard(SeedFactory());
// A new reference proposing "temperature" collides with REF-2's OVERRIDE effective name.
var result = await guard.CheckAsync(
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'temperature' already used by reference '{RefWithOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task New_virtualtag_vs_scripted_alarm_clash_returns_error_naming_the_alarm()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckAsync(
EquipmentId, "highpress", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'highpress' already used by ScriptedAlarm '{ScriptedAlarmId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Self_exclude_on_override_change_returns_null()
{
var guard = Guard(SeedFactory());
// Editing REF-2 to change its override to "temperature" (its own current effective name)
// must NOT collide with itself.
var result = await guard.CheckAsync(
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: RefWithOverrideId);
result.ShouldBeNull();
}
[Fact]
public async Task Self_exclude_only_applies_to_same_kind()
{
var guard = Guard(SeedFactory());
// Excluding a VirtualTag whose id happens to equal a reference id must NOT excuse the
// reference collision — the self-row is identified by (kind, id) together.
var result = await guard.CheckAsync(
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: RefNoOverrideId);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Ordinal_case_sensitivity_Speed_does_not_collide_with_speed()
{
var guard = Guard(SeedFactory());
// "Speed" (capital S) must NOT collide with the existing "speed" (ordinal comparison).
var result = await guard.CheckAsync(
EquipmentId, "Speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
[Fact]
public async Task Collision_is_scoped_to_the_equipment()
{
var guard = Guard(SeedFactory());
// Same name, different equipment → no collision (per-equipment NodeId space).
var result = await guard.CheckAsync(
"EQ-000000000002", "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(name)
.Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}