257 lines
9.5 KiB
C#
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());
|
|
}
|
|
}
|