09ff43910c
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
232 lines
8.8 KiB
C#
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());
|
|
}
|
|
}
|