feat(raw): B3-WP3 rename-warning script scan + historized-override refine

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
This commit is contained in:
Joseph Doherty
2026-07-16 06:04:06 -04:00
parent a88dc86173
commit 09ff43910c
2 changed files with 357 additions and 27 deletions
@@ -1282,24 +1282,41 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <summary>
/// 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).
/// </summary>
private readonly record struct AffectedTag(
string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name);
/// <summary>
/// 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 <c>historianTagname</c> 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
/// <see cref="Script"/> body (tolerates false positives by design — no AST).
/// </summary>
private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct)
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var warnings = new List<string>();
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<OtOpcUaConfigDbContext> 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;
}
/// <summary>
/// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain ordinal
/// substring (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT
/// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning
/// naming the matched scripts, or <see langword="null"/> when none match.
/// </summary>
private static async Task<string?> BuildScriptReferenceWarningAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> 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.";
}
/// <summary>
/// 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.
/// </summary>
private static async Task<IReadOnlyList<string>> ComputeOldRawPathsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> 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<string>(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;
}
/// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary>
private static async Task<string> DescribeEquipmentAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct)
@@ -1336,11 +1435,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
// --- Subtree tag collectors (walk the in-DB subtree for rename impact) ---
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathFolderAsync(
private static async Task<IReadOnlyList<AffectedTag>> 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<AffectedTag>();
// 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<OtOpcUaConfigDbContext> 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<AffectedTag>();
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<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct);
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDriverAsync(
private static async Task<IReadOnlyList<AffectedTag>> 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<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct);
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDeviceAsync(
private static async Task<IReadOnlyList<AffectedTag>> 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<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathGroupAsync(
private static async Task<IReadOnlyList<AffectedTag>> 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<AffectedTag>();
var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId)
@@ -1412,20 +1511,20 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> 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<IReadOnlyList<(string TagId, string TagConfig)>> TagsForDevicesAsync(
private static async Task<IReadOnlyList<AffectedTag>> TagsForDevicesAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> 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();
}
@@ -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;
/// <summary>
/// Covers the B3-WP3 rename-warning extension of <see cref="RawTreeService"/>: the three warning kinds a
/// tag/ancestor rename can raise — (a) historized WITHOUT a <c>historianTagname</c> override (history
/// forks), (b) UNS-referenced (names the equipment), and (c) matched by a substring scan of a
/// <c>Script</c> body for the affected tag's OLD RawPath.
/// <para>
/// Seeds a bespoke InMemory slice (independent of the shared <c>RawTreeTestDb</c>) so each device holds
/// exactly one tag with a controlled property mix. The EF InMemory provider does not enforce
/// <c>RowVersion</c> tokens, so renames commit against the seeded row versions.
/// </para>
/// </summary>
[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<OtOpcUaConfigDbContext>().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<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}