Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceRenameWarningTests.cs
T
Joseph Doherty 09ff43910c 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
2026-07-16 06:04:06 -04:00

232 lines
8.8 KiB
C#

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