Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EffectiveNameGuardTests.cs
T

257 lines
9.5 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();
}
/// <summary>
/// The batch check is a pure round-trip saving: for every proposed name it must return exactly
/// what <see cref="EffectiveNameGuard.CheckAsync"/> returns for the FIRST colliding name in input
/// order — including the source-specific message text — across all three source kinds.
/// </summary>
[Theory]
// First collision is the no-override reference ("speed"), even though an alarm name follows it.
[InlineData(new[] { "free-a", "speed", "highpress", "temperature" }, "speed")]
// First collision is the OVERRIDE-derived reference effective name.
[InlineData(new[] { "free-a", "temperature", "speed" }, "temperature")]
// First collision is the VirtualTag.
[InlineData(new[] { "free-a", "computed", "speed" }, "computed")]
// First collision is the ScriptedAlarm.
[InlineData(new[] { "free-a", "highpress", "speed" }, "highpress")]
public async Task CheckMany_matches_CheckAsync_verdicts(string[] names, string expectedCollidingName)
{
var factory = SeedFactory();
var guard = Guard(factory);
var expected = await guard.CheckAsync(
EquipmentId, expectedCollidingName, EffectiveNameSourceKind.Reference, excludeSourceId: null);
expected.ShouldNotBeNull();
var actual = await guard.CheckManyAsync(EquipmentId, names, EffectiveNameSourceKind.Reference);
actual.ShouldBe(expected);
}
/// <summary>A batch where every proposed name is free reports no collision.</summary>
[Fact]
public async Task CheckMany_all_free_returns_null()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckManyAsync(
EquipmentId,
["free-a", "free-b", "Speed" /* ordinal: capital S does not collide */, "free-c"],
EffectiveNameSourceKind.Reference);
result.ShouldBeNull();
}
/// <summary>An empty batch is trivially free (and must not throw).</summary>
[Fact]
public async Task CheckMany_empty_batch_returns_null()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckManyAsync(EquipmentId, [], EffectiveNameSourceKind.Reference);
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());
}
}