perf(adminui): one guard round-trip per bulk reference add
This commit is contained in:
@@ -190,6 +190,59 @@ public sealed class EffectiveNameGuardTests
|
||||
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() =>
|
||||
|
||||
@@ -23,13 +23,24 @@ public sealed class UnsTreeServiceReferenceTests
|
||||
{
|
||||
private const string EquipmentId = "EQ-1";
|
||||
|
||||
/// <summary>A configurable <see cref="IEffectiveNameGuard"/>: returns <see cref="Result"/> from every check.</summary>
|
||||
/// <summary>
|
||||
/// A configurable <see cref="IEffectiveNameGuard"/>: returns <see cref="Result"/> from every check.
|
||||
/// <see cref="CheckManyAsync"/> is implemented explicitly (rather than inheriting the interface's
|
||||
/// looping default) so <see cref="CheckManyCalls"/> counts BATCH round-trips — that count is what
|
||||
/// pins "one guard round-trip per bulk add, regardless of how many tags were selected".
|
||||
/// </summary>
|
||||
private sealed class FakeGuard : IEffectiveNameGuard
|
||||
{
|
||||
public string? Result { get; set; }
|
||||
|
||||
public string? LastProposedName { get; private set; }
|
||||
|
||||
/// <summary>How many times the BATCH entry point was called.</summary>
|
||||
public int CheckManyCalls { get; private set; }
|
||||
|
||||
/// <summary>The names passed to the last <see cref="CheckManyAsync"/> call, in input order.</summary>
|
||||
public IReadOnlyList<string> LastProposedNames { get; private set; } = [];
|
||||
|
||||
public Task<string?> CheckAsync(
|
||||
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId, CancellationToken ct = default)
|
||||
@@ -37,6 +48,16 @@ public sealed class UnsTreeServiceReferenceTests
|
||||
LastProposedName = proposedEffectiveName;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public Task<string?> CheckManyAsync(
|
||||
string equipmentId, IReadOnlyList<string> proposedEffectiveNames,
|
||||
EffectiveNameSourceKind kind, CancellationToken ct = default)
|
||||
{
|
||||
CheckManyCalls++;
|
||||
LastProposedNames = [.. proposedEffectiveNames];
|
||||
LastProposedName = proposedEffectiveNames.Count > 0 ? proposedEffectiveNames[^1] : null;
|
||||
return Task.FromResult(proposedEffectiveNames.Count > 0 ? Result : null);
|
||||
}
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext Db(string name) =>
|
||||
@@ -178,6 +199,47 @@ public sealed class UnsTreeServiceReferenceTests
|
||||
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A multi-tag add asks the guard ONCE — one batch call carrying every proposed effective name in
|
||||
/// selection order — instead of one round-trip per selected tag.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddReferences_asks_the_guard_once_for_the_whole_batch()
|
||||
{
|
||||
var name = $"ref-batch-guard-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var guard = new FakeGuard();
|
||||
var service = new UnsTreeService(Factory(name), guard);
|
||||
|
||||
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-running", "TAG-grouped"]);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
guard.CheckManyCalls.ShouldBe(1);
|
||||
guard.LastProposedNames.ShouldBe(new[] { "speed", "running", "nested" });
|
||||
|
||||
using var db = Db(name);
|
||||
db.UnsTagReferences.Count(r => r.EquipmentId == EquipmentId).ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>A collision reported by the batch guard fails the whole add; nothing is persisted.</summary>
|
||||
[Fact]
|
||||
public async Task AddReferences_batch_guard_collision_rejected()
|
||||
{
|
||||
var name = $"ref-batch-guard-fail-{Guid.NewGuid():N}";
|
||||
Seed(name);
|
||||
var guard = new FakeGuard { Result = "'running' already exists on equipment 'machine-1'." };
|
||||
var service = new UnsTreeService(Factory(name), guard);
|
||||
|
||||
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-running"]);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("'running' already exists on equipment 'machine-1'.");
|
||||
guard.CheckManyCalls.ShouldBe(1);
|
||||
|
||||
using var db = Db(name);
|
||||
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- SetReferenceOverride -----
|
||||
|
||||
/// <summary>Setting an override changes the effective name; clearing it falls back to the raw name.</summary>
|
||||
|
||||
Reference in New Issue
Block a user