merge worktree-agent-a10be14b6713011cd into v3/batch3-uns-rework
This commit is contained in:
@@ -49,6 +49,10 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
// v3 Batch 3 (WP2): authoring-time UNS effective-name uniqueness guard. Consumed by
|
||||
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
|
||||
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
|
||||
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
|
||||
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
|
||||
services.AddScoped<RawTagCsvExportReader>();
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IEffectiveNameGuard"/>. Loads the owning equipment's current effective-name
|
||||
/// set — references (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>),
|
||||
/// VirtualTags (<c>Name</c>), and ScriptedAlarms (<c>Name</c>) — and reports the first collision
|
||||
/// with a proposed name. Comparison is <see cref="StringComparer.Ordinal"/> to match the
|
||||
/// <c>{EquipmentId}/{EffectiveName}</c> NodeId semantics and the deploy-time
|
||||
/// <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each call creates and disposes its own pooled context — the same per-call pattern
|
||||
/// <see cref="UnsTreeService"/> uses — so the guard is safe to register <c>Scoped</c>.
|
||||
/// </remarks>
|
||||
public sealed class EffectiveNameGuard(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IEffectiveNameGuard
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> CheckAsync(
|
||||
string equipmentId,
|
||||
string proposedEffectiveName,
|
||||
EffectiveNameSourceKind kind,
|
||||
string? excludeSourceId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// References for this equipment. The effective name is the display-name override else the
|
||||
// backing raw tag's current Name, so the two sets (references + tags) are loaded and joined
|
||||
// in memory — the per-equipment set is small and this mirrors the deploy rule exactly
|
||||
// (a reference whose backing tag is missing contributes no effective name, so it is skipped).
|
||||
var references = await db.UnsTagReferences
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => new { r.UnsTagReferenceId, r.TagId, r.DisplayNameOverride })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var tagIds = references.Select(r => r.TagId).Distinct(StringComparer.Ordinal).ToList();
|
||||
var tagNameById = (await db.Tags
|
||||
.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.Name })
|
||||
.ToListAsync(ct))
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||
|
||||
foreach (var r in references)
|
||||
{
|
||||
if (IsSelf(EffectiveNameSourceKind.Reference, r.UnsTagReferenceId, kind, excludeSourceId))
|
||||
continue;
|
||||
var effective = r.DisplayNameOverride
|
||||
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (Collides(effective, proposedEffectiveName))
|
||||
return Message(proposedEffectiveName, "reference", r.UnsTagReferenceId, equipmentId);
|
||||
}
|
||||
|
||||
var virtualTags = await db.VirtualTags
|
||||
.AsNoTracking()
|
||||
.Where(v => v.EquipmentId == equipmentId)
|
||||
.Select(v => new { v.VirtualTagId, v.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var v in virtualTags)
|
||||
{
|
||||
if (IsSelf(EffectiveNameSourceKind.VirtualTag, v.VirtualTagId, kind, excludeSourceId))
|
||||
continue;
|
||||
if (Collides(v.Name, proposedEffectiveName))
|
||||
return Message(proposedEffectiveName, "VirtualTag", v.VirtualTagId, equipmentId);
|
||||
}
|
||||
|
||||
var scriptedAlarms = await db.ScriptedAlarms
|
||||
.AsNoTracking()
|
||||
.Where(a => a.EquipmentId == equipmentId)
|
||||
.Select(a => new { a.ScriptedAlarmId, a.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var a in scriptedAlarms)
|
||||
{
|
||||
if (IsSelf(EffectiveNameSourceKind.ScriptedAlarm, a.ScriptedAlarmId, kind, excludeSourceId))
|
||||
continue;
|
||||
if (Collides(a.Name, proposedEffectiveName))
|
||||
return Message(proposedEffectiveName, "ScriptedAlarm", a.ScriptedAlarmId, equipmentId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>True when a row is the self-row being edited — same kind AND same logical id — so it
|
||||
/// is excluded from the collision set (a rename / override-change of a row cannot collide with itself).</summary>
|
||||
private static bool IsSelf(
|
||||
EffectiveNameSourceKind rowKind,
|
||||
string rowId,
|
||||
EffectiveNameSourceKind editedKind,
|
||||
string? excludeSourceId) =>
|
||||
excludeSourceId is not null
|
||||
&& rowKind == editedKind
|
||||
&& string.Equals(rowId, excludeSourceId, StringComparison.Ordinal);
|
||||
|
||||
private static bool Collides(string? existing, string proposed) =>
|
||||
existing is not null && string.Equals(existing, proposed, StringComparison.Ordinal);
|
||||
|
||||
private static string Message(string name, string sourceLabel, string sourceId, string equipmentId) =>
|
||||
$"effective name '{name}' already used by {sourceLabel} '{sourceId}' in equipment '{equipmentId}'";
|
||||
}
|
||||
@@ -205,6 +205,71 @@ public sealed class DraftValidatorTests
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
/// <summary>The rename-induced case the authoring guard never sees: an authoring-clean draft where
|
||||
/// a reference (no override, effective name = backing raw tag's current Name) ends up sharing an
|
||||
/// effective name with a VirtualTag after a raw-tag rename. The deploy gate is the backstop.</summary>
|
||||
[Fact]
|
||||
public void Reference_effective_from_raw_name_collides_with_virtualtag_after_rename()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "speed")], // raw tag renamed to "speed"
|
||||
UnsTagReferences =
|
||||
[
|
||||
// No override → effective name is the backing raw tag's Name ("speed").
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
|
||||
};
|
||||
|
||||
var errors = DraftValidator.Validate(draft);
|
||||
errors.ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
// Names both colliding sources + the equipment.
|
||||
var msg = errors.First(e => e.Code == "UnsEffectiveNameCollision").Message;
|
||||
msg.ShouldContain("ref-1");
|
||||
msg.ShouldContain("vtag-eq-1-speed");
|
||||
msg.ShouldContain("eq-1");
|
||||
}
|
||||
|
||||
/// <summary>A reference's DisplayNameOverride (not its backing raw name) is the effective name and
|
||||
/// must be unique against a VirtualTag in the same equipment.</summary>
|
||||
[Fact]
|
||||
public void Reference_override_collides_with_virtualtag()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "raw-name")], // backing raw name differs
|
||||
UnsTagReferences =
|
||||
[
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: "computed"),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "computed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
/// <summary>Effective-name comparison is ordinal: "Speed" (VirtualTag) and "speed" (reference's
|
||||
/// backing raw name) do NOT collide — they are distinct OPC UA browse names.</summary>
|
||||
[Fact]
|
||||
public void Effective_name_collision_is_ordinal_case_sensitive()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Tags = [BuildTag(tagId: "tag-1", name: "speed")],
|
||||
UnsTagReferences =
|
||||
[
|
||||
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
|
||||
],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "Speed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
|
||||
}
|
||||
|
||||
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
|
||||
{
|
||||
VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}",
|
||||
@@ -214,6 +279,24 @@ public sealed class DraftValidatorTests
|
||||
ScriptId = "s-1",
|
||||
};
|
||||
|
||||
private static Tag BuildTag(string tagId, string name) => new()
|
||||
{
|
||||
TagId = tagId,
|
||||
DeviceId = "dev-1",
|
||||
Name = name,
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static UnsTagReference BuildReference(string refId, string equipmentId, string tagId, string? overrideName) => new()
|
||||
{
|
||||
UnsTagReferenceId = refId,
|
||||
EquipmentId = equipmentId,
|
||||
TagId = tagId,
|
||||
DisplayNameOverride = overrideName,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3)
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user