b68c313372
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
204 lines
7.3 KiB
C#
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());
|
|
}
|
|
}
|