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()); + } +}