From 6dc5af7aa25caadbc04317b7b8ca0027476165e5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 05:53:52 -0400 Subject: [PATCH 01/10] v3 Batch 3 contracts: IEffectiveNameGuard authoring-time uniqueness seam Wave-A shared contract. WP2 implements + registers the guard; WP1 consumes it in UnsTreeService reference/VirtualTag/ScriptedAlarm mutations. Ordinal, mirrors the deploy-time DraftValidator UnsEffectiveNameCollision rule. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Uns/IEffectiveNameGuard.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IEffectiveNameGuard.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IEffectiveNameGuard.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IEffectiveNameGuard.cs new file mode 100644 index 00000000..959d8acd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IEffectiveNameGuard.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Which authoring surface a proposed effective name comes from. Used to word the collision +/// error and to exclude the correct same-kind self-row on an edit (rename / override change). +/// +public enum EffectiveNameSourceKind +{ + /// A (effective name = + /// DisplayNameOverride else the backing raw tag's Name). + Reference, + + /// An equipment-bound (effective name = Name). + VirtualTag, + + /// An equipment-bound (effective name = Name). + ScriptedAlarm, +} + +/// +/// v3 Batch 3 authoring-time guard for the UNS effective-name uniqueness rule. Within an +/// equipment the EFFECTIVE leaf name must be unique across its UnsTagReferences +/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags +/// (Name), and its ScriptedAlarms (Name) — the three share the equipment's +/// UNS NodeId space ({EquipmentId}/{EffectiveName}). This is the authoring mirror of the +/// deploy-time DraftValidator UnsEffectiveNameCollision rule; comparison is +/// to match NodeId semantics and the validator. +/// +/// +/// Injectable (registered Scoped, its own pooled context per call — the same pattern +/// UnsTreeService uses). Consumed by UnsTreeService's reference / virtual-tag / +/// scripted-alarm mutations, which call before persisting and surface a +/// non-null result as the mutation's readable failure. The deploy gate remains the backstop for +/// rename-induced collisions the authoring check never saw. +/// +public interface IEffectiveNameGuard +{ + /// + /// Tests whether would collide with an existing + /// effective name within . + /// + /// The owning equipment's logical id. + /// The effective name to be authored (override else raw name for a reference; Name for a VT/alarm). + /// Which surface the proposed name is for (words the error; identifies the self-row kind to exclude). + /// + /// On an edit, the logical id of the row being changed (its UnsTagReferenceId / + /// VirtualTagId / ScriptedAlarmId) so it is excluded from the collision set; + /// on a create. + /// + /// Cancellation token. + /// + /// when the name is free; otherwise a readable error naming the + /// colliding existing source and the equipment. + /// + Task CheckAsync( + string equipmentId, + string proposedEffectiveName, + EffectiveNameSourceKind kind, + string? excludeSourceId, + CancellationToken ct = default); +} From b68c3133727c79b5a0a774a6527b8517bb964039 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 06:01:10 -0400 Subject: [PATCH 02/10] feat(uns-v3): implement EffectiveNameGuard authoring-time uniqueness guard (B3-WP2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../EndpointRouteBuilderExtensions.cs | 4 + .../Uns/EffectiveNameGuard.cs | 106 +++++++++ .../DraftValidatorTests.cs | 83 +++++++ .../Uns/EffectiveNameGuardTests.cs | 203 ++++++++++++++++++ 4 files changed, 396 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EffectiveNameGuardTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 7239ee9e..72a33228 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -49,6 +49,10 @@ public static class EndpointRouteBuilderExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // 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(); // WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude). services.AddScoped(); services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs new file mode 100644 index 00000000..97c8d124 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Default . Loads the owning equipment's current effective-name +/// set — references (DisplayNameOverride else the backing raw tag's Name), +/// VirtualTags (Name), and ScriptedAlarms (Name) — and reports the first collision +/// with a proposed name. Comparison is to match the +/// {EquipmentId}/{EffectiveName} NodeId semantics and the deploy-time +/// DraftValidator UnsEffectiveNameCollision rule. +/// +/// +/// Each call creates and disposes its own pooled context — the same per-call pattern +/// uses — so the guard is safe to register Scoped. +/// +public sealed class EffectiveNameGuard(IDbContextFactory dbFactory) : IEffectiveNameGuard +{ + /// + public async Task 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; + } + + /// 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). + 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}'"; +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs index adcf5dd8..6c5ca786 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs @@ -205,6 +205,71 @@ public sealed class DraftValidatorTests DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision"); } + /// 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. + [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"); + } + + /// A reference's DisplayNameOverride (not its backing raw name) is the effective name and + /// must be unique against a VirtualTag in the same equipment. + [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"); + } + + /// Effective-name comparison is ordinal: "Speed" (VirtualTag) and "speed" (reference's + /// backing raw name) do NOT collide — they are distinct OPC UA browse names. + [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) // ------------------------------------------------------------------------------------ diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EffectiveNameGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EffectiveNameGuardTests.cs new file mode 100644 index 00000000..fe1ec492 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EffectiveNameGuardTests.cs @@ -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; + +/// +/// Unit tests for — 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. +/// +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 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 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 + { + public OtOpcUaConfigDbContext CreateDbContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(name) + .Options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(CreateDbContext()); + } +} From 09ff43910c3027db0ed57160b7770a412fdf2dcc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 06:04:06 -0400 Subject: [PATCH 03/10] feat(raw): B3-WP3 rename-warning script scan + historized-override refine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend RawTreeService.BuildRenameWarningsAsync so a tag/ancestor rename now raises three warning kinds over the affected (prefix-scanned) tags: (a) refined — historized WITHOUT a historianTagname override (default tagname is the moving RawPath, so history forks); a pinned (override) tag no longer warns. Count/message adjusted. (b) unchanged — UNS-referenced (names the equipment). (c) NEW — substring-scan every Script body for the affected tag's OLD RawPath (ordinal, false-positives tolerated by design; no AST). OLD RawPaths are recomputed from the pre-save in-DB topology via RawPathResolver. CollectTagsBeneath* + TagsForDevices now carry (DeviceId, TagGroupId, Name) so the OLD RawPath can be rebuilt. Contained entirely within RawTreeService.cs; IRawTreeService + RawTree.razor untouched (warnings flow through the existing RawRenameResult.Warnings). Note: a direct tag rename goes through UpdateTagAsync (UnsMutationResult, no warning surface) so it is out of scope without widening the interface. Tests: new RawTreeServiceRenameWarningTests (4) — all-three, pinned-no-warn, unrelated-empty, ancestor-rename script match. AdminUI.Tests 642 green. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Uns/RawTreeService.cs | 153 ++++++++++-- .../Uns/RawTreeServiceRenameWarningTests.cs | 231 ++++++++++++++++++ 2 files changed, 357 insertions(+), 27 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index f619c59e..1039b03c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1282,24 +1282,41 @@ public sealed class RawTreeService(IDbContextFactory dbF } /// - /// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and - /// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname / - /// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here. + /// A raw tag affected by a rename, carrying the fields needed to (a) read its platform intents and + /// (b) recompute its OLD RawPath (still the in-DB state at warning time — the rename is not yet saved). + /// + private readonly record struct AffectedTag( + string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name); + + /// + /// Builds the non-blocking rename warnings answerable from this batch's schema for the tags beneath the + /// renamed node (whose RawPath — and thus historian tagname / UNS projection path / script literal — + /// changes with the rename): (a) historized tags WITHOUT a historianTagname override (their + /// history forks, since the default tagname is the moving RawPath; pinned tags are unaffected), + /// (b) equipment-referenced tags, and (c) tags whose OLD RawPath appears as a literal substring in any + /// body (tolerates false positives by design — no AST). /// private static async Task> BuildRenameWarningsAsync( - OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct) + OtOpcUaConfigDbContext db, IReadOnlyList tags, CancellationToken ct) { var warnings = new List(); if (tags.Count == 0) return warnings; - var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized); - if (historized > 0) + // (a) Historized WITHOUT a historianTagname override: the default tagname is the RawPath, which is + // moving, so history forks on the next deploy. A tag WITH the override is pinned and does not warn. + var forking = tags.Count(t => { - warnings.Add(historized == 1 - ? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path." - : $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths."); + var intent = TagConfigIntent.Parse(t.TagConfig); + return intent.IsHistorized && intent.HistorianTagname is null; + }); + if (forking > 0) + { + warnings.Add(forking == 1 + ? "1 historized tag beneath this node has no historianTagname override; its history forks to a new tagname when its RawPath changes on the next deploy. Set a historianTagname to pin it." + : $"{forking} historized tags beneath this node have no historianTagname override; their history forks to new tagnames when their RawPaths change on the next deploy. Set a historianTagname to pin them."); } + // (b) UNS-referenced: name the referencing equipment. var tagIds = tags.Select(t => t.TagId).ToList(); var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() .Where(r => tagIds.Contains(r.TagId)) @@ -1313,12 +1330,94 @@ public sealed class RawTreeService(IDbContextFactory dbF warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename."); } - // Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append - // a "N script(s) reference tags beneath this node" warning to this same list. + // (c) Script literals: substring-scan every Script body for the affected tags' OLD RawPaths. + var scriptWarning = await BuildScriptReferenceWarningAsync(db, tags, ct); + if (scriptWarning is not null) warnings.Add(scriptWarning); return warnings; } + /// + /// Scans every body for the OLD RawPath of any affected tag as a plain ordinal + /// substring (no AST — false positives tolerated by design; {{equip}}-relative refs are NOT + /// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning + /// naming the matched scripts, or when none match. + /// + private static async Task BuildScriptReferenceWarningAsync( + OtOpcUaConfigDbContext db, IReadOnlyList tags, CancellationToken ct) + { + var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct); + if (oldPaths.Count == 0) return null; + + var scripts = await db.Scripts.AsNoTracking() + .Select(s => new { s.ScriptId, s.Name, s.SourceCode }) + .ToListAsync(ct); + + var matched = scripts + .Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal))) + .OrderBy(s => s.Name, StringComparer.Ordinal) + .Select(s => s.Name) + .ToList(); + if (matched.Count == 0) return null; + + var names = string.Join(", ", matched.Select(n => $"'{n}'")); + return matched.Count == 1 + ? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated." + : $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated."; + } + + /// + /// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology, + /// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected + /// tags need: their devices + drivers, the drivers' clusters' folders, and the devices' tag groups. + /// + private static async Task> ComputeOldRawPathsAsync( + OtOpcUaConfigDbContext db, IReadOnlyList tags, CancellationToken ct) + { + var deviceIds = tags.Select(t => t.DeviceId).Distinct(StringComparer.Ordinal).ToList(); + + var deviceRows = await db.Devices.AsNoTracking() + .Where(d => deviceIds.Contains(d.DeviceId)) + .Select(d => new { d.DeviceId, d.DriverInstanceId, d.Name }) + .ToListAsync(ct); + + var driverIds = deviceRows.Select(d => d.DriverInstanceId).Distinct(StringComparer.Ordinal).ToList(); + var driverRows = await db.DriverInstances.AsNoTracking() + .Where(d => driverIds.Contains(d.DriverInstanceId)) + .Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name, d.ClusterId }) + .ToListAsync(ct); + + var clusterIds = driverRows.Select(d => d.ClusterId).Distinct(StringComparer.Ordinal).ToList(); + var folderRows = await db.RawFolders.AsNoTracking() + .Where(f => clusterIds.Contains(f.ClusterId)) + .Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name }) + .ToListAsync(ct); + + var groupRows = await db.TagGroups.AsNoTracking() + .Where(g => deviceIds.Contains(g.DeviceId)) + .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name }) + .ToListAsync(ct); + + var folders = folderRows.ToDictionary( + f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal); + var drivers = driverRows.ToDictionary( + d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal); + var devices = deviceRows.ToDictionary( + d => d.DeviceId, d => (d.DriverInstanceId, d.Name), StringComparer.Ordinal); + var groups = groupRows.ToDictionary( + g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal); + + var resolver = new RawPathResolver(folders, drivers, devices, groups); + + var paths = new List(tags.Count); + foreach (var t in tags) + { + var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name); + if (path is not null) paths.Add(path); + } + return paths; + } + /// Renders a friendly "Name (Id), …" list for the referencing equipment ids. private static async Task DescribeEquipmentAsync( OtOpcUaConfigDbContext db, IReadOnlyList equipmentIds, CancellationToken ct) @@ -1336,11 +1435,11 @@ public sealed class RawTreeService(IDbContextFactory dbF // --- Subtree tag collectors (walk the in-DB subtree for rename impact) --- - private static async Task> CollectTagsBeneathFolderAsync( + private static async Task> CollectTagsBeneathFolderAsync( OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct) { var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); - if (folder is null) return Array.Empty<(string, string)>(); + if (folder is null) return Array.Empty(); // All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId. var clusterFolders = await db.RawFolders.AsNoTracking() @@ -1359,44 +1458,44 @@ public sealed class RawTreeService(IDbContextFactory dbF .Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId)) .Select(d => d.DriverInstanceId) .ToListAsync(ct); - if (driverIds.Count == 0) return Array.Empty<(string, string)>(); + if (driverIds.Count == 0) return Array.Empty(); var deviceIds = await db.Devices.AsNoTracking() .Where(dev => driverIds.Contains(dev.DriverInstanceId)) .Select(dev => dev.DeviceId) .ToListAsync(ct); - if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + if (deviceIds.Count == 0) return Array.Empty(); return await TagsForDevicesAsync(db, deviceIds, ct); } - private static async Task> CollectTagsBeneathDriverAsync( + private static async Task> CollectTagsBeneathDriverAsync( OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct) { var deviceIds = await db.Devices.AsNoTracking() .Where(dev => dev.DriverInstanceId == driverInstanceId) .Select(dev => dev.DeviceId) .ToListAsync(ct); - if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + if (deviceIds.Count == 0) return Array.Empty(); return await TagsForDevicesAsync(db, deviceIds, ct); } - private static async Task> CollectTagsBeneathDeviceAsync( + private static async Task> CollectTagsBeneathDeviceAsync( OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct) { return (await db.Tags.AsNoTracking() .Where(t => t.DeviceId == deviceId) - .Select(t => new { t.TagId, t.TagConfig }) + .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name }) .ToListAsync(ct)) - .Select(t => (t.TagId, t.TagConfig)) + .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name)) .ToList(); } - private static async Task> CollectTagsBeneathGroupAsync( + private static async Task> CollectTagsBeneathGroupAsync( OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct) { var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); - if (group is null) return Array.Empty<(string, string)>(); + if (group is null) return Array.Empty(); var deviceGroups = await db.TagGroups.AsNoTracking() .Where(g => g.DeviceId == group.DeviceId) @@ -1412,20 +1511,20 @@ public sealed class RawTreeService(IDbContextFactory dbF return (await db.Tags.AsNoTracking() .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId)) - .Select(t => new { t.TagId, t.TagConfig }) + .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name }) .ToListAsync(ct)) - .Select(t => (t.TagId, t.TagConfig)) + .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name)) .ToList(); } - private static async Task> TagsForDevicesAsync( + private static async Task> TagsForDevicesAsync( OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) { return (await db.Tags.AsNoTracking() .Where(t => deviceIds.Contains(t.DeviceId)) - .Select(t => new { t.TagId, t.TagConfig }) + .Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name }) .ToListAsync(ct)) - .Select(t => (t.TagId, t.TagConfig)) + .Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name)) .ToList(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningTests.cs new file mode 100644 index 00000000..ef2a7fc3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningTests.cs @@ -0,0 +1,231 @@ +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; + +/// +/// Covers the B3-WP3 rename-warning extension of : the three warning kinds a +/// tag/ancestor rename can raise — (a) historized WITHOUT a historianTagname override (history +/// forks), (b) UNS-referenced (names the equipment), and (c) matched by a substring scan of a +/// Script body for the affected tag's OLD RawPath. +/// +/// Seeds a bespoke InMemory slice (independent of the shared RawTreeTestDb) so each device holds +/// exactly one tag with a controlled property mix. The EF InMemory provider does not enforce +/// RowVersion tokens, so renames commit against the seeded row versions. +/// +/// +[Trait("Category", "Unit")] +public sealed class RawTreeServiceRenameWarningTests +{ + private const string ClusterId = "MAIN"; + private const string FolderId = "RF-plant"; + private const string DriverId = "DRV-modbus"; + + // Dev-all → the "everything" tag: historized (no override) + referenced + named in a script literal. + private const string DevAllId = "DEV-all"; + private const string FlowTagId = "TAG-flow"; + private const string FlowRawPath = "Plant/modbus-1/Dev-all/flow"; + + // Dev-pinned → a historized tag WITH a historianTagname override (pinned; must NOT warn). + private const string DevPinnedId = "DEV-pinned"; + private const string TempTagId = "TAG-temp"; + + // Dev-plain → a plain tag, unreferenced + absent from every script (must warn nothing). + private const string DevPlainId = "DEV-plain"; + private const string IdleTagId = "TAG-idle"; + + private const string EquipmentId = "EQ-000000000001"; + private const string EquipmentName = "packer-1"; + private const string ScriptName = "AreaCalc"; + + private static (RawTreeService Service, string DbName) Seeded() + { + var name = $"rawwarn-{Guid.NewGuid():N}"; + using var db = CreateNamed(name); + Seed(db); + return (new RawTreeService(new NamedFactory(name)), name); + } + + private static OtOpcUaConfigDbContext CreateNamed(string name) => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + private static byte[] DeviceRowVersion(string dbName, string deviceId) + { + using var db = CreateNamed(dbName); + return db.Devices.Single(d => d.DeviceId == deviceId).RowVersion; + } + + private static byte[] DriverRowVersion(string dbName, string driverId) + { + using var db = CreateNamed(dbName); + return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion; + } + + // ------------------------------------------------------------------------------------- Scenarios + + [Fact] + public async Task Rename_of_ancestor_of_a_historized_referenced_scripted_tag_raises_all_three_warnings() + { + var (service, dbName) = Seeded(); + var rv = DeviceRowVersion(dbName, DevAllId); + + var result = await service.RenameDeviceAsync(DevAllId, "Dev-renamed", rv); + + result.Ok.ShouldBeTrue(); + result.Warnings.ShouldContain(w => w.Contains("historian")); // (a) + result.Warnings.ShouldContain(w => w.Contains(EquipmentName)); // (b) + result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName)); // (c) + result.Warnings.Count.ShouldBe(3); + } + + [Fact] + public async Task Rename_of_a_pinned_historized_tag_raises_no_historized_warning() + { + var (service, dbName) = Seeded(); + var rv = DeviceRowVersion(dbName, DevPinnedId); + + var result = await service.RenameDeviceAsync(DevPinnedId, "Dev-pinned2", rv); + + result.Ok.ShouldBeTrue(); + // A historianTagname override pins the tagname, so its history does NOT fork — no (a) warning; and + // the pinned tag is neither referenced nor scripted, so nothing else fires either. + result.Warnings.ShouldNotContain(w => w.Contains("historian")); + result.Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task Rename_of_an_unrelated_tags_ancestor_raises_no_warnings() + { + var (service, dbName) = Seeded(); + var rv = DeviceRowVersion(dbName, DevPlainId); + + var result = await service.RenameDeviceAsync(DevPlainId, "Dev-plain2", rv); + + result.Ok.ShouldBeTrue(); + result.Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task Script_scan_matches_when_an_ancestor_is_renamed_and_the_old_path_is_in_a_script_body() + { + var (service, dbName) = Seeded(); + var rv = DriverRowVersion(dbName, DriverId); + + // Renaming the DRIVER (an ancestor of the scripted tag) still computes the tag's unchanged OLD + // RawPath (rename not yet saved) and finds it as a substring of the script body. + var result = await service.RenameDriverAsync(DriverId, "modbus-renamed", rv); + + result.Ok.ShouldBeTrue(); + result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName)); + } + + // ------------------------------------------------------------------------------------------ Seed + + private static void Seed(OtOpcUaConfigDbContext db) + { + db.ServerClusters.Add(new ServerCluster + { + ClusterId = ClusterId, + Name = "Main", + Enterprise = "zb", + Site = "warsaw-west", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + + db.RawFolders.Add(new RawFolder + { + RawFolderId = FolderId, + ClusterId = ClusterId, + ParentRawFolderId = null, + Name = "Plant", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = DriverId, + ClusterId = ClusterId, + RawFolderId = FolderId, + Name = "modbus-1", + DriverType = "Modbus", + DriverConfig = "{}", + }); + + db.Devices.Add(new Device { DeviceId = DevAllId, DriverInstanceId = DriverId, Name = "Dev-all", DeviceConfig = "{}" }); + db.Devices.Add(new Device { DeviceId = DevPinnedId, DriverInstanceId = DriverId, Name = "Dev-pinned", DeviceConfig = "{}" }); + db.Devices.Add(new Device { DeviceId = DevPlainId, DriverInstanceId = DriverId, Name = "Dev-plain", DeviceConfig = "{}" }); + + // Historized, no historianTagname override → its default tagname is FlowRawPath, which moves. + db.Tags.Add(new Tag + { + TagId = FlowTagId, + DeviceId = DevAllId, + TagGroupId = null, + Name = "flow", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"isHistorized\":true}", + }); + // Historized WITH a historianTagname override → pinned, must not fork/warn. + db.Tags.Add(new Tag + { + TagId = TempTagId, + DeviceId = DevPinnedId, + TagGroupId = null, + Name = "temp", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"Plant.Pinned.Temp\"}", + }); + // Plain, unreferenced, absent from scripts. + db.Tags.Add(new Tag + { + TagId = IdleTagId, + DeviceId = DevPlainId, + TagGroupId = null, + Name = "idle", + DataType = "Boolean", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + + db.Equipment.Add(new Equipment + { + EquipmentId = EquipmentId, + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-1", + Name = EquipmentName, + MachineCode = "packer_001", + }); + db.UnsTagReferences.Add(new UnsTagReference + { + UnsTagReferenceId = "REF-flow", + EquipmentId = EquipmentId, + TagId = FlowTagId, + }); + + // A script whose body names the flow tag by its absolute RawPath literal. + db.Scripts.Add(new Script + { + ScriptId = "SC-areacalc", + Name = ScriptName, + SourceCode = $"var v = ctx.GetTag(\"{FlowRawPath}\"); return v * 2;", + SourceHash = "hash-areacalc", + }); + + db.SaveChanges(); + } + + private sealed class NamedFactory(string name) : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(CreateDbContext()); + } +} From 77bc010ba95c404aa6a3e85c0ded52536263c35d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 06:15:51 -0400 Subject: [PATCH 04/10] =?UTF-8?q?v3=20Batch=203=20WP1:=20UNS=20Equipment?= =?UTF-8?q?=20Tags=20tab=20=E2=86=92=20reference=20list=20+=20raw-tag=20pi?= =?UTF-8?q?cker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the equipment page's Tags tab (v3 reference-only equipment): columns are effective name, computed raw path, inherited datatype/access (read-only), display-name override (editable), and remove. The retired equipment-authored-tag editor (TagModal.razor) is deleted; the VirtualTag and ScriptedAlarm flows are untouched. - "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw usage is byte-unchanged (PickerMode defaults false). - UnsTreeService (+ IUnsTreeService) gain the reference mutations: LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver), AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync, SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync / LoadDescendantTagIdsAsync. - Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered): AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update mutations now call CheckAsync before persisting and surface a collision as the failure. WP2 supplies the implementation + DI registration. - Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput, the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the dead decision-#122 driver-cluster guard. Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers); Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI suite green (639 passed, 3 skipped); full solution builds 0 errors. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Components/Pages/Uns/EquipmentPage.razor | 136 ++--- .../Components/Shared/Raw/RawTree.razor | 94 ++- .../Shared/Uns/AddReferenceModal.razor | 125 ++++ .../Shared/Uns/ImportEquipmentModal.razor | 24 +- .../Components/Shared/Uns/TagModal.razor | 557 ------------------ .../Uns/EquipmentChildRows.cs | 25 + .../Uns/EquipmentInput.cs | 2 - .../Uns/IUnsTreeService.cs | 102 +++- .../Uns/UnsTreeService.cs | 465 ++++++++++++--- .../Uns/UnsTreeServiceEquipmentTests.cs | 253 ++------ .../Uns/UnsTreeServiceImportTests.cs | 124 +--- .../Uns/UnsTreeServiceReferenceTests.cs | 314 ++++++++++ 12 files changed, 1161 insertions(+), 1060 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceReferenceTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor index b1711eeb..34f99e9d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor @@ -78,17 +78,9 @@ else -
- - - - @foreach (var (id, display) in _driverOptions) - { - - } - -
+ @* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced + here by reference on the Tags tab. The old "Driver instance" select was removed. *@
@@ -138,38 +130,48 @@ else } else if (_activeTab == "tags") { + @* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at + raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name + override is the only per-reference editable field. *@
- +
- @if (!string.IsNullOrWhiteSpace(_tagError)) + @if (!string.IsNullOrWhiteSpace(_refError)) { -
@_tagError
+
@_refError
} - @if (_tags is null) + @if (_refs is null) {

Loading…

} - else if (_tags.Count == 0) + else if (_refs.Count == 0) { -

No tags yet.

+

No tag references yet.

} else { - +
- + + + + - @foreach (var t in _tags) + @foreach (var r in _refs) { - - - - - - + + + + + + } @@ -177,9 +179,8 @@ else
NameDriverData typeAccessActions
Effective nameRaw pathData typeAccessDisplay-name overrideActions
@t.Name@t.DriverInstanceId@t.DataType@t.AccessLevel - - +
@r.EffectiveName@r.RawPath@r.DataType@r.AccessLevel + + + +
} - + } else if (_activeTab == "vtags") { @@ -290,15 +291,13 @@ else private EquipmentEditDto? _equipment; private FormModel _form = new(); private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>(); - private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>(); - // --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). --- - private IReadOnlyList? _tags; - private string? _tagError; - private bool _tagModalVisible; - private bool _tagModalIsNew; - private TagEditDto? _tagModalExisting; - private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>(); + // --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load + + // spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. --- + private IReadOnlyList? _refs; + private readonly Dictionary _overrideEdits = new(StringComparer.Ordinal); + private string? _refError; + private bool _refModalVisible; // --- Virtual Tags tab state. _vtags is null until the tab is first activated. --- private IReadOnlyList? _vtags; @@ -329,54 +328,48 @@ else { _activeTab = tab; if (IsNew) { return; } - if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); } + if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); } else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); } else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); } } - // --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) --- + // --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) --- - private async Task ReloadTagsAsync() + private async Task ReloadReferencesAsync() { - _tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!); + _refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!); + // Seed the per-row override edit buffer so each row's binds to a live value. + _overrideEdits.Clear(); + foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; } } - private async Task OpenAddTag() + private void OpenAddReference() { - _tagError = null; - _tagModalIsNew = true; - _tagModalExisting = null; - _tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!); - _tagModalVisible = true; + _refError = null; + _refModalVisible = true; } - private async Task OpenEditTag(string tagId) + private async Task OnReferencesCommittedAsync() { - _tagError = null; - var dto = await Svc.LoadTagAsync(tagId); - if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; } - _tagModalIsNew = false; - _tagModalExisting = dto; - _tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!); - _tagModalVisible = true; + _refModalVisible = false; + await ReloadReferencesAsync(); } - private async Task OnTagSavedAsync() + private async Task SaveOverride(EquipmentReferenceRow r) { - _tagModalVisible = false; - await ReloadTagsAsync(); + _refError = null; + var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId); + var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion); + if (res.Ok) { await ReloadReferencesAsync(); } + else { _refError = res.Error; } } - private async Task DeleteTag(string tagId) + private async Task RemoveReference(EquipmentReferenceRow r) { - _tagModalVisible = false; - _tagError = null; - // Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete. - var dto = await Svc.LoadTagAsync(tagId); - if (dto is null) { await ReloadTagsAsync(); return; } - var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion); - if (r.Ok) { await ReloadTagsAsync(); } - else { _tagError = r.Error; } + _refError = null; + var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion); + if (res.Ok) { await ReloadReferencesAsync(); } + else { _refError = res.Error; } } // --- Virtual Tags tab handlers --- @@ -478,7 +471,7 @@ else // path lands on Details because the field initializes to "details" and a fresh page instance // starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the // lazy loaders re-fetch for the (possibly different) equipment this parameter set targets. - _tags = null; + _refs = null; _vtags = null; _alarms = null; if (!IsNew) @@ -489,7 +482,6 @@ else LoadFormFrom(_equipment); var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId); _lineOptions = ctx.Lines; - _driverOptions = ctx.Drivers; } } else @@ -497,7 +489,6 @@ else _form = new FormModel { UnsLineId = LineId ?? "" }; var ctx = await Svc.LoadEquipmentPickContextAsync(LineId); _lineOptions = ctx.Lines; - _driverOptions = ctx.Drivers; } _loading = false; } @@ -510,7 +501,6 @@ else Name = e.Name, MachineCode = e.MachineCode, UnsLineId = e.UnsLineId, - DriverInstanceId = e.DriverInstanceId, ZTag = e.ZTag, SAPID = e.SAPID, Manufacturer = e.Manufacturer, @@ -536,7 +526,6 @@ else _form.Name, _form.MachineCode, _form.UnsLineId, - _form.DriverInstanceId, _form.ZTag, _form.SAPID, _form.Manufacturer, @@ -582,7 +571,6 @@ else public string Name { get; set; } = ""; [Required] public string MachineCode { get; set; } = ""; [Required] public string UnsLineId { get; set; } = ""; - public string? DriverInstanceId { get; set; } public string? ZTag { get; set; } public string? SAPID { get; set; } public string? Manufacturer { get; set; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor index 6aa815f3..fef10dcd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor @@ -69,6 +69,28 @@ /// [Parameter] public EventCallback OnRootsChanged { get; set; } + // --------------------------------------------------------------------------- + // v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged). + // In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select + // checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment + // page's "+ Add reference" modal drives this against a single cluster-scoped root. + // --------------------------------------------------------------------------- + + /// When true, render the tree as a raw-tag picker (checkboxes + select-all) instead of + /// the editing tree. Default false — the /raw editing usage is unaffected. + [Parameter] public bool PickerMode { get; set; } + + /// The shared, caller-owned set of selected raw TagIds (picker mode only). The component + /// mutates it in place and raises after every change. + [Parameter] public HashSet? SelectedTagIds { get; set; } + + /// Raised after the selection set changes (picker mode) so the host can update its count / footer. + [Parameter] public EventCallback OnSelectionChanged { get; set; } + + /// Supplies the raw TagIds beneath a Device/TagGroup node for the "select all tags below" + /// affordance (picker mode). The host wires this to IUnsTreeService.LoadDescendantTagIdsAsync. + [Parameter] public Func>>? ResolveDescendantTagIds { get; set; } + // --- Configure-driver modal (edit) --- private bool _driverCfgVisible; private string? _driverCfgId; @@ -198,6 +220,12 @@ private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder => { + @if (PickerMode && node.Kind == RawNodeKind.Tag) + { + + } @node.DisplayName @if (muted) @@ -270,17 +298,67 @@ // named handlers below are the seam Wave B/C replaces with the real modals. // --------------------------------------------------------------------------- - private IReadOnlyList MenuFor(RawNode node) => node.Kind switch + private IReadOnlyList MenuFor(RawNode node) { - RawNodeKind.Cluster => ClusterMenu(node), - RawNodeKind.Folder => FolderMenu(node), - RawNodeKind.Driver => DriverMenu(node), - RawNodeKind.Device => DeviceMenu(node), - RawNodeKind.TagGroup => TagGroupMenu(node), - RawNodeKind.Tag => TagMenu(node), - _ => Array.Empty(), // Enterprise: label only, no menu. + // Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below" + // affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu. + if (PickerMode) + { + return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup + ? PickerContainerMenu(node) + : Array.Empty(); + } + + return node.Kind switch + { + RawNodeKind.Cluster => ClusterMenu(node), + RawNodeKind.Folder => FolderMenu(node), + RawNodeKind.Driver => DriverMenu(node), + RawNodeKind.Device => DeviceMenu(node), + RawNodeKind.TagGroup => TagGroupMenu(node), + RawNodeKind.Tag => TagMenu(node), + _ => Array.Empty(), // Enterprise: label only, no menu. + }; + } + + // --- Picker-mode menu + selection helpers --- + + private List PickerContainerMenu(RawNode node) => new() + { + new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) }, + new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) }, }; + private bool IsTagSelected(RawNode node) => + node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId); + + private async Task ToggleTagSelection(RawNode node, bool selected) + { + if (SelectedTagIds is null || node.EntityId is null) { return; } + if (selected) { SelectedTagIds.Add(node.EntityId); } + else { SelectedTagIds.Remove(node.EntityId); } + await OnSelectionChanged.InvokeAsync(); + StateHasChanged(); + } + + private async Task SelectAllUnderAsync(RawNode node) + { + if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; } + var ids = await ResolveDescendantTagIds(node); + foreach (var id in ids) { SelectedTagIds.Add(id); } + await OnSelectionChanged.InvokeAsync(); + StateHasChanged(); + } + + private async Task ClearAllUnderAsync(RawNode node) + { + if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; } + var ids = await ResolveDescendantTagIds(node); + foreach (var id in ids) { SelectedTagIds.Remove(id); } + await OnSelectionChanged.InvokeAsync(); + StateHasChanged(); + } + private List ClusterMenu(RawNode node) => new() { new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor new file mode 100644 index 00000000..3d05abd8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor @@ -0,0 +1,125 @@ +@* v3 Batch 3 "+ Add reference" picker: reuses the Raw project tree (RawTree) in picker mode to + multi-select raw tags, scoped to the equipment's own cluster. The cluster scope is structural — + IUnsTreeService.LoadReferencePickerRootAsync hands back a SINGLE cluster root, so raw tags of any + other cluster are simply unreachable through the tree, not merely hidden. On commit the selected + tag ids are added as UnsTagReference rows via AddReferencesAsync; the host reloads its Tags list. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw +@inject IUnsTreeService Svc + +@if (Visible) +{ + + +} + +@code { + /// Whether the modal is shown. The host owns this flag. + [Parameter] public bool Visible { get; set; } + + /// The equipment the selected raw tags are referenced under. + [Parameter] public string? EquipmentId { get; set; } + + /// Raised after references are committed so the host can reload its Tags list and close. + [Parameter] public EventCallback OnCommitted { get; set; } + + /// Raised when the user cancels so the host can close. + [Parameter] public EventCallback OnCancel { get; set; } + + private IReadOnlyList _roots = Array.Empty(); + private readonly HashSet _selected = new(StringComparer.Ordinal); + private bool _busy; + private bool _loading; + private string? _error; + private bool _wasVisible; + + protected override async Task OnParametersSetAsync() + { + // Reset + reload only on the false→true transition so re-renders while open don't wipe state. + if (Visible && !_wasVisible) + { + _selected.Clear(); + _error = null; + _roots = Array.Empty(); + _loading = true; + StateHasChanged(); + + var root = string.IsNullOrEmpty(EquipmentId) + ? null + : await Svc.LoadReferencePickerRootAsync(EquipmentId); + _roots = root is null ? Array.Empty() : new[] { root }; + _loading = false; + } + _wasVisible = Visible; + } + + private Task> ResolveDescendantsAsync(RawNode node) => + Svc.LoadDescendantTagIdsAsync(node); + + // RawTree mutates _selected in place; this just re-renders the footer count. + private void OnSelectionChanged() => StateHasChanged(); + + private async Task CommitAsync() + { + if (_selected.Count == 0) { return; } + _busy = true; + _error = null; + try + { + var res = await Svc.AddReferencesAsync(EquipmentId!, _selected.ToList()); + if (res.Ok) { await OnCommitted.InvokeAsync(); } + else { _error = res.Error; } + } + finally + { + _busy = false; + } + } + + private Task CancelAsync() => OnCancel.InvokeAsync(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor index e6cf2cf4..759875b4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor @@ -2,9 +2,10 @@ the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported so the page can reload the whole tree (an import can add equipment across many lines/clusters). - Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId. + Required header columns (in order): Name, MachineCode, UnsLineId. Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and - skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@ + skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. + v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @inject IUnsTreeService Svc @@ -21,7 +22,7 @@ - + @if (!string.IsNullOrWhiteSpace(_scriptError)) {
@_scriptError
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index e6f03fd9..d7004c2d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -23,13 +23,16 @@ public interface IScriptTagCatalog /// The resolved tag info, or when is not a known configured path. Task GetTagInfoAsync(string path, CancellationToken ct); - /// Distinct attribute leaf names — the substring after the first dot of each - /// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion, - /// which needs the per-equipment object prefix stripped. - /// Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded). + /// v3: the owning equipment's UnsTagReference effective names (its + /// DisplayNameOverride else the backing raw tag's Name) — the resolvable set for + /// {{equip}}/<RefName> completion + diagnostics, matching what the compose seams substitute + /// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has + /// no references. + /// The equipment whose references to list; blank returns empty. + /// Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded). /// Cancellation token. - /// Distinct leaf names. - Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct); + /// Distinct reference effective names. + Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct); } /// Resolved info for one configured tag/virtual-tag path (for hover). @@ -114,17 +117,32 @@ public sealed class ScriptTagCatalog(IDbContextFactory d } /// - public async Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct) + public async Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct) { - var entries = await BuildEntriesAsync(ct); - var leaves = new HashSet(StringComparer.Ordinal); - foreach (var e in entries) + if (string.IsNullOrEmpty(equipmentId)) return Array.Empty(); + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var refs = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => new { r.TagId, r.DisplayNameOverride }) + .ToListAsync(ct); + if (refs.Count == 0) return Array.Empty(); + + var tagIds = refs.Select(r => r.TagId).Distinct().ToList(); + var tagNameById = await db.Tags.AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.Name }) + .ToDictionaryAsync(t => t.TagId, t => t.Name, ct); + + // Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set). + var names = new HashSet(StringComparer.Ordinal); + foreach (var r in refs) { - var dot = e.Path.IndexOf('.'); - if (dot < 0 || dot + 1 >= e.Path.Length) continue; - leaves.Add(e.Path.Substring(dot + 1)); + var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (!string.IsNullOrEmpty(eff)) names.Add(eff!); } - IEnumerable q = leaves; + + IEnumerable q = names; if (!string.IsNullOrWhiteSpace(filter)) q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase)); return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 62f962a4..2c71031c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -1,15 +1,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; -public record DiagnoseRequest(string Code); +// EquipmentId (optional) carries the owning-equipment context the {{equip}}/ completion + +// diagnostic need to resolve reference effective names. Null on the shared ScriptEdit page (no single +// equipment) — the equip-ref features are then inert, matching the deploy gate (which only resolves per +// owning equipment). +public record DiagnoseRequest(string Code, string? EquipmentId = null); public record DiagnoseResponse(IReadOnlyList Markers); public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn, int EndLineNumber, int EndColumn, string Message, string Code); -public record CompletionsRequest(string CodeText, int Line, int Column); +public record CompletionsRequest(string CodeText, int Line, int Column, string? EquipmentId = null); public record CompletionsResponse(IReadOnlyList Items); public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0); -public record HoverRequest(string CodeText, int Line, int Column); +public record HoverRequest(string CodeText, int Line, int Column, string? EquipmentId = null); public record HoverResponse(string? Markdown); public record SignatureHelpRequest(string CodeText, int Line, int Column); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 7fec4785..6783f56f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -16,7 +16,7 @@ public static class ScriptAnalysisEndpoints // ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate. var group = endpoints.MapGroup("/api/script-analysis") .RequireAuthorization(AdminUiPolicies.ConfigEditor); - group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); + group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r))); group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 32331a0e..663ef22c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService } } + // ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring + // EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/ diagnostic is scoped to the + // SAME path literals the compose seams substitute (never a comment / logger string). + private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex = + new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", + System.Text.RegularExpressions.RegexOptions.Compiled); + + /// Diagnostics plus the v3 equipment-relative {{equip}}/<RefName> resolution check. + /// Runs the synchronous Roslyn/policy then, when an equipment context + catalog + /// are present, appends a marker for each {{equip}}/<RefName> whose <RefName> is + /// not one of the equipment's reference effective names — flagging exactly what the deploy gate + /// (DraftValidator.ValidateEquipReferenceResolution) rejects, so the editor accepts ⇔ publish + /// accepts. Inert (base markers only) on the shared ScriptEdit page where EquipmentId is null. + /// The diagnose request (its EquipmentId carries the owning-equipment context). + /// The base diagnostics plus any unresolved-reference markers. + public async Task DiagnoseAsync(DiagnoseRequest req) + { + var baseResp = Diagnose(req); + if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code)) + return baseResp; + try + { + var code = Normalize(req.Code); + if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) + return baseResp; + + var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None); + var resolvable = new HashSet(names, StringComparer.Ordinal); + var markers = new List(baseResp.Markers); + + foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code)) + { + var content = m.Groups[2].Value; + if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue; + var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length); + if (refName.Length == 0 || resolvable.Contains(refName)) continue; + // Mark the whole literal content span (the {{equip}}/ path). + var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length); + markers.Add(ToUserMarker(code, span, + $"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " + + $"reference named '{refName}'. Add a matching reference or correct the path.", + "OTSCRIPT_EQUIPREF")); + } + return new DiagnoseResponse(markers.Distinct().ToList()); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers."); + return baseResp; + } + } + private static int Sev(DiagnosticSeverity s) => s switch { DiagnosticSeverity.Error => 8, @@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _)) { - const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}." - if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal)) + // v3: {{equip}}/ completes the owning equipment's UnsTagReference effective names + // (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null). + const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/" + if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal)) { - var leaves = await _catalog.GetEquipmentRelativeLeavesAsync( - pathPrefix.Substring(equipDot.Length), CancellationToken.None); - return new CompletionsResponse(leaves - .Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field")) + if (string.IsNullOrEmpty(req.EquipmentId)) + return new CompletionsResponse(Array.Empty()); + var names = await _catalog.GetEquipmentReferenceNamesAsync( + req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None); + return new CompletionsResponse(names + .Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field")) .ToList()); } @@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService { return new HoverResponse( $"**Equipment-relative path** `{Code(tagPath)}`\n\n" + - "The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed."); + "`{{equip}}/` resolves through the owning equipment's tag reference " + + "(by effective name) to the backing raw tag's path when the VirtualTag is deployed."); } var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 0a12e7ce..864c648f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -866,29 +866,45 @@ public sealed class UnsTreeService(IDbContextFactory dbF } /// - /// When the bound script uses the reserved {{equip}} token, the owning equipment must have - /// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result - /// when the token is present but no base can be derived; null otherwise (token absent, or a - /// base exists). The script source is fetched from the supplied (in-scope) context. + /// v3 (M1): when the bound script uses {{equip}}/<RefName>, every <RefName> must + /// resolve to one of the owning equipment's UnsTagReference effective names (its + /// DisplayNameOverride else the backing raw tag's Name) — the same set the compose seams + /// substitute against and the deploy gate (DraftValidator.ValidateEquipReferenceResolution) + /// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; null + /// otherwise (no slash-token, or all resolve). Replaces the v2 DeriveEquipmentBase(empty)→null + /// check, which rejected ALL {{equip}} VirtualTags with a now-impossible "add a driver tag" message. /// private static async Task ValidateEquipTokenAsync( OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct) { var src = await db.Scripts.Where(s => s.ScriptId == scriptId) .Select(s => s.SourceCode).FirstOrDefaultAsync(ct); - if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null; + var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src); + if (used.Count == 0) return null; - // The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver - // tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here. - var fullNames = Array.Empty(); - if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null) + // The equipment's reference effective-name set: DisplayNameOverride else the backing raw tag's Name. + var refs = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.EquipmentId == equipmentId) + .Select(r => new { r.TagId, r.DisplayNameOverride }) + .ToListAsync(ct); + var tagIds = refs.Select(r => r.TagId).ToList(); + var tagNameById = await db.Tags.AsNoTracking() + .Where(t => tagIds.Contains(t.TagId)) + .Select(t => new { t.TagId, t.Name }) + .ToDictionaryAsync(t => t.TagId, t => t.Name, ct); + var effectiveNames = new HashSet(StringComparer.Ordinal); + foreach (var r in refs) { - return new UnsMutationResult(false, - $"Equipment '{equipmentId}' has no single tag base, so the " - + $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this " - + $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script."); + var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null); + if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!); } - return null; + + var missing = used.Where(u => !effectiveNames.Contains(u)).ToList(); + if (missing.Count == 0) return null; + return new UnsMutationResult(false, + $"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} " + + $"but equipment '{equipmentId}' has no reference with that effective name. " + + "Add a matching reference on the Tags tab, or correct the script."); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js index 714a7e37..e90c63bc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js @@ -23,6 +23,11 @@ const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 }; + // The owning-equipment id is stamped onto each editor's model at createEditor time so the globally + // registered csharp providers (which only receive the model) can thread it into the {{equip}}/ + // completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there). + function equipmentIdOf(model) { return (model && model.__otEquipmentId) || null; } + function registerCSharpProviders() { monaco.languages.registerCompletionItemProvider("csharp", { triggerCharacters: [".", "(", "\""], @@ -31,7 +36,7 @@ const resp = await fetch("/api/script-analysis/completions", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) + body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return { suggestions: [] }; const data = await resp.json(); @@ -72,7 +77,7 @@ const resp = await fetch("/api/script-analysis/hover", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column }) + body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return null; const data = await resp.json(); @@ -149,7 +154,7 @@ const resp = await fetch("/api/script-analysis/diagnostics", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: model.getValue() }) + body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) }) }); if (!resp.ok) return []; const data = await resp.json(); @@ -192,6 +197,9 @@ // inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space. quickSuggestions: { other: true, comments: false, strings: true } }); + // Stamp the owning-equipment id on the model so the global csharp providers can thread it into the + // {{equip}}/ completion + diagnostics (null on the shared ScriptEdit page). + try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {} let diagTimer = null; const scheduleDiagnostics = function () { if (diagTimer) clearTimeout(diagTimer); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 1625aec6..24d46012 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -315,6 +315,11 @@ public static class AddressSpaceComposer /// The scripted alarms. /// The per-equipment virtual (calculated) tags. null = none. /// The scripts joined to by ScriptId for the expression. null = none. + /// The UNS tag references (equipment → raw tag). Feed the {{equip}}/<RefName> reference map. null = none. + /// The raw tags backing (RawPath computed via the shared resolver). null = none. + /// The raw-tree folders (RawPath ancestry). null = none. + /// The raw-tree devices (RawPath ancestry). null = none. + /// The raw-tree tag-groups (RawPath ancestry). null = none. /// The composition result. public static AddressSpaceComposition Compose( IReadOnlyList unsAreas, @@ -323,7 +328,12 @@ public static class AddressSpaceComposer IReadOnlyList driverInstances, IReadOnlyList scriptedAlarms, IReadOnlyList? virtualTags = null, - IReadOnlyList