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