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(Action? extra = null) { var name = $"rawwarn-{Guid.NewGuid():N}"; using var db = CreateNamed(name); Seed(db); extra?.Invoke(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)); } [Fact] public async Task Duplicate_old_paths_are_deduplicated_before_the_scan() { // Two tags share a name under the same device, so both resolve to the SAME old RawPath — the scan // must not carry the duplicate into its predicate, and the warning must still name the script once. var (service, dbName) = Seeded(db => { db.Tags.Add(new Tag { TagId = "TAG-flow-dup", DeviceId = DevAllId, TagGroupId = null, Name = "flow", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }); db.SaveChanges(); }); var rv = DeviceRowVersion(dbName, DevAllId); var result = await service.RenameDeviceAsync(DevAllId, "Dev-renamed", rv); result.Ok.ShouldBeTrue(); var scriptWarnings = result.Warnings.Where(w => w.Contains("script")).ToList(); scriptWarnings.Count.ShouldBe(1); scriptWarnings[0].ShouldBe( $"1 script ('{ScriptName}') references a tag beneath this node by its raw path; " + "the reference will break unless the script is updated."); } [Fact] public async Task A_rename_with_more_paths_than_one_batch_still_finds_matches_in_every_batch() { // 401 affected tags → three scan batches at the 200-term cap. One script names a path in the first // batch, one in the middle, one in the last: a loop that drops any batch loses a name. const int bulkTags = 401; var (service, dbName) = Seeded(db => { db.Devices.Add(new Device { DeviceId = "DEV-bulk", DriverInstanceId = DriverId, Name = "Dev-bulk", DeviceConfig = "{}" }); for (var i = 0; i < bulkTags; i++) { db.Tags.Add(new Tag { TagId = $"TAG-bulk-{i:D3}", DeviceId = "DEV-bulk", TagGroupId = null, Name = $"bulk-{i:D3}", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }); } foreach (var (scriptName, index) in new[] { ("S-first", 0), ("S-middle", 200), ("S-last", bulkTags - 1) }) { db.Scripts.Add(new Script { ScriptId = $"SC-{scriptName}", Name = scriptName, SourceCode = $"var v = ctx.GetTag(\"Plant/modbus-1/Dev-bulk/bulk-{index:D3}\"); return v;", SourceHash = $"hash-{scriptName}", }); } db.SaveChanges(); }); var rv = DeviceRowVersion(dbName, "DEV-bulk"); var result = await service.RenameDeviceAsync("DEV-bulk", "Dev-bulk2", rv); result.Ok.ShouldBeTrue(); // Ordinal sort: 'S-first' < 'S-last' < 'S-middle'. result.Warnings.ShouldContain( "3 scripts ('S-first', 'S-last', 'S-middle') reference tags beneath this node by their raw " + "paths; the references will break unless the scripts are updated."); } // ------------------------------------------------------------------------------------------ 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()); } }