using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Verifies : the converter that /// rewrites pure-relay VirtualTags (body == return ctx.GetTag("X").Value;) into equivalent /// Galaxy alias rows, deletes the relay VirtualTag, and prunes the orphaned /// when nothing else references it. Covers conversion, the skip reasons /// (non-relay, historized, no gateway, unresolvable {{equip}}), dry-run, scope, shared-script /// retention/pruning, and the {{equip}} expansion path. /// [Trait("Category", "Unit")] public sealed class UnsTreeServiceRelayConverterTests { private const string ClusterId = "MAIN"; private const string GatewayDriverId = "DRV-GALAXY"; private const string ModbusDriverId = "DRV-MODBUS"; /// /// Seeds a cluster with a SystemPlatform namespace + GalaxyMxGateway driver (optional), an /// Equipment-kind namespace + Modbus driver, an area→line, and returns the InMemory db name. /// Equipment rows are added by the caller via . /// private static string SeedCluster(bool withGalaxyGateway = true) { var dbName = $"uns-relay-{Guid.NewGuid():N}"; using var db = UnsTreeTestDb.CreateNamed(dbName); db.ServerClusters.Add(new ServerCluster { ClusterId = ClusterId, Name = "Main", Enterprise = "zb", Site = "warsaw-west", RedundancyMode = RedundancyMode.None, CreatedBy = "test", }); db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = ClusterId, Name = "a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); db.Namespaces.Add(new Namespace { NamespaceId = "NS-SP", ClusterId = ClusterId, Kind = NamespaceKind.SystemPlatform, NamespaceUri = "urn:zb:sp", }); if (withGalaxyGateway) { db.DriverInstances.Add(new DriverInstance { DriverInstanceId = GatewayDriverId, ClusterId = ClusterId, NamespaceId = "NS-SP", Name = "galaxy gateway", DriverType = "GalaxyMxGateway", DriverConfig = "{}", }); } db.Namespaces.Add(new Namespace { NamespaceId = "NS-EQ", ClusterId = ClusterId, Kind = NamespaceKind.Equipment, NamespaceUri = "urn:zb:eq", }); db.DriverInstances.Add(new DriverInstance { DriverInstanceId = ModbusDriverId, ClusterId = ClusterId, NamespaceId = "NS-EQ", Name = "modbus driver", DriverType = "Modbus", DriverConfig = "{}", }); db.SaveChanges(); return dbName; } /// Adds an equipment under LINE-1 in the seeded cluster. private static void AddEquipment(string dbName, string equipmentId, string machineCode) { using var db = UnsTreeTestDb.CreateNamed(dbName); db.Equipment.Add(new Equipment { EquipmentId = equipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-1", Name = equipmentId, MachineCode = machineCode, }); db.SaveChanges(); } /// Adds a script row with the given source. private static void AddScript(string dbName, string scriptId, string source) { using var db = UnsTreeTestDb.CreateNamed(dbName); db.Scripts.Add(new Script { ScriptId = scriptId, Name = scriptId, SourceCode = source, SourceHash = $"hash-{scriptId}", }); db.SaveChanges(); } /// Adds a virtual tag bound to a script on an equipment. private static void AddVirtualTag( string dbName, string virtualTagId, string equipmentId, string name, string scriptId, string dataType = "Int32", bool historize = false) { using var db = UnsTreeTestDb.CreateNamed(dbName); db.VirtualTags.Add(new VirtualTag { VirtualTagId = virtualTagId, EquipmentId = equipmentId, Name = name, DataType = dataType, ScriptId = scriptId, Historize = historize, }); db.SaveChanges(); } /// Adds an existing Galaxy alias Tag on an equipment (used for {{equip}} base derivation). private static void AddAliasTag(string dbName, string tagId, string equipmentId, string name, string fullName) { using var db = UnsTreeTestDb.CreateNamed(dbName); db.Tags.Add(new Tag { TagId = tagId, DriverInstanceId = GatewayDriverId, EquipmentId = equipmentId, Name = name, DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = $"{{\"FullName\":\"{fullName}\"}}", }); db.SaveChanges(); } private static string Relay(string reference) => $"return ctx.GetTag(\"{reference}\").Value;"; // ----- Case 1: exact relay converted (apply) ----- /// /// A pure-relay virtual tag is replaced by a Galaxy alias Tag (gateway-bound, FolderPath null, /// AccessLevel Read, FullName + DataType carried over). The relay VirtualTag and its now-orphan /// Script are gone. The result reports the item and Applied == true. /// [Fact] public async Task Exact_relay_is_converted_to_alias_tag() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.TestChangingInt")); AddVirtualTag(dbName, "VT-1", "EQ-1", "speed-rpm", "SCRIPT-1", dataType: "Int32"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Applied.ShouldBeTrue(); var item = result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed-rpm"); item.EquipmentId.ShouldBe("EQ-1"); item.FullName.ShouldBe("TestMachine_020.TestChangingInt"); item.DataType.ShouldBe("Int32"); result.Skipped.ShouldBeEmpty(); using var db = UnsTreeTestDb.CreateNamed(dbName); var tag = db.Tags.Single(t => t.Name == "speed-rpm"); tag.DriverInstanceId.ShouldBe(GatewayDriverId); tag.EquipmentId.ShouldBe("EQ-1"); tag.FolderPath.ShouldBeNull(); tag.AccessLevel.ShouldBe(TagAccessLevel.Read); tag.DataType.ShouldBe("Int32"); tag.WriteIdempotent.ShouldBeFalse(); tag.PollGroupId.ShouldBeNull(); using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig); doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.TestChangingInt"); db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeFalse(); db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeFalse(); } // ----- Case 2: non-relay untouched ----- /// A computed body (not a pure relay) is skipped; the VirtualTag and Script remain. [Fact] public async Task Non_relay_is_skipped_and_unchanged() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddScript(dbName, "SCRIPT-1", "return ctx.GetTag(\"A.B\").Value * 2;"); AddVirtualTag(dbName, "VT-1", "EQ-1", "doubled", "SCRIPT-1"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Converted.ShouldBeEmpty(); var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "doubled"); skip.Reason.ShouldContain("relay"); using var db = UnsTreeTestDb.CreateNamed(dbName); db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue(); db.Tags.Any(t => t.Name == "doubled").ShouldBeFalse(); } // ----- Case 3a: two relays share a script -> deleted after both convert ----- /// /// One script backs two relay virtual tags. A fleet-wide apply converts both, so the now-orphan /// script is deleted. /// [Fact] public async Task Shared_script_deleted_when_all_referencing_relays_converted() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddEquipment(dbName, "EQ-2", "m_002"); AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared")); AddVirtualTag(dbName, "VT-A", "EQ-1", "a", "SCRIPT-SHARED"); AddVirtualTag(dbName, "VT-B", "EQ-2", "b", "SCRIPT-SHARED"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync(null, dryRun: false); result.Converted.Count.ShouldBe(2); using var db = UnsTreeTestDb.CreateNamed(dbName); db.VirtualTags.Any().ShouldBeFalse(); db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeFalse(); db.Tags.Count(t => t.Name == "a" || t.Name == "b").ShouldBe(2); } // ----- Case 3b: scoped convert keeps the script while another relay still needs it ----- /// /// One script backs two relay virtual tags on different equipments. A scoped apply (EQ-1 only) /// converts only the first; the script remains because the second relay still references it. A /// follow-up apply for EQ-2 then deletes the script. /// [Fact] public async Task Shared_script_kept_then_deleted_across_scoped_passes() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddEquipment(dbName, "EQ-2", "m_002"); AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared")); AddVirtualTag(dbName, "VT-A", "EQ-1", "a", "SCRIPT-SHARED"); AddVirtualTag(dbName, "VT-B", "EQ-2", "b", "SCRIPT-SHARED"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var first = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); first.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "a"); using (var db = UnsTreeTestDb.CreateNamed(dbName)) { db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeTrue(); db.VirtualTags.Any(v => v.VirtualTagId == "VT-B").ShouldBeTrue(); db.VirtualTags.Any(v => v.VirtualTagId == "VT-A").ShouldBeFalse(); } var second = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-2", dryRun: false); second.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "b"); using (var db = UnsTreeTestDb.CreateNamed(dbName)) { db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeFalse(); } } // ----- Case 3c: relay shares its script with a NON-relay vtag (scoped) -> script kept ----- /// /// A relay shares its (pure-relay) script with another virtual tag on a different equipment. A /// scoped apply converts only the relay's equipment; the script is kept because the other virtual /// tag — left outside scope, so it is not a conversion candidate this pass — still references it. /// This is the "still used by a not-removed vtag" branch of the orphan check. /// [Fact] public async Task Script_kept_when_other_referencing_vtag_is_out_of_scope() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddEquipment(dbName, "EQ-2", "m_002"); AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared")); AddVirtualTag(dbName, "VT-RELAY", "EQ-1", "relayed", "SCRIPT-SHARED"); AddVirtualTag(dbName, "VT-OTHER", "EQ-2", "other", "SCRIPT-SHARED"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "relayed"); using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeTrue(); verify.VirtualTags.Any(v => v.VirtualTagId == "VT-OTHER").ShouldBeTrue(); verify.VirtualTags.Any(v => v.VirtualTagId == "VT-RELAY").ShouldBeFalse(); } // ----- Case 3d: relay shares its script with a ScriptedAlarm predicate -> script kept ----- /// /// A relay's script is also a ScriptedAlarm predicate. Converting the relay must leave the script /// in place because the alarm still references it. /// [Fact] public async Task Script_kept_when_referenced_by_scripted_alarm() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddScript(dbName, "SCRIPT-PRED", Relay("TestMachine_020.Pred")); AddVirtualTag(dbName, "VT-RELAY", "EQ-1", "relayed", "SCRIPT-PRED"); using (var db = UnsTreeTestDb.CreateNamed(dbName)) { db.ScriptedAlarms.Add(new ScriptedAlarm { ScriptedAlarmId = "AL-1", EquipmentId = "EQ-1", Name = "alarm", AlarmType = "AlarmCondition", MessageTemplate = "x", PredicateScriptId = "SCRIPT-PRED", }); db.SaveChanges(); } var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "relayed"); using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.Scripts.Any(s => s.ScriptId == "SCRIPT-PRED").ShouldBeTrue(); verify.VirtualTags.Any(v => v.VirtualTagId == "VT-RELAY").ShouldBeFalse(); } // ----- Case 4: Historize=true skipped ----- /// A historized relay virtual tag is skipped (Tag has no historize column) and unchanged. [Fact] public async Task Historized_relay_is_skipped() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.Hist")); AddVirtualTag(dbName, "VT-1", "EQ-1", "histtag", "SCRIPT-1", historize: true); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Converted.ShouldBeEmpty(); var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "histtag"); skip.Reason.ShouldContain("historiz", Case.Insensitive); using var db = UnsTreeTestDb.CreateNamed(dbName); db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue(); db.Tags.Any(t => t.Name == "histtag").ShouldBeFalse(); } // ----- Case 5: no gateway ----- /// A relay on an equipment whose cluster has no Galaxy gateway is skipped, unchanged. [Fact] public async Task Relay_skipped_when_no_galaxy_gateway() { var dbName = SeedCluster(withGalaxyGateway: false); AddEquipment(dbName, "EQ-1", "m_001"); AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.X")); AddVirtualTag(dbName, "VT-1", "EQ-1", "nogw", "SCRIPT-1"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Converted.ShouldBeEmpty(); var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "nogw"); skip.Reason.ShouldContain("no Galaxy gateway"); using var db = UnsTreeTestDb.CreateNamed(dbName); db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); db.Tags.Any(t => t.Name == "nogw").ShouldBeFalse(); } // ----- Case 6: dry-run no mutation ----- /// A dry-run lists the convertible relay but mutates nothing; Applied == false. [Fact] public async Task Dry_run_lists_but_does_not_mutate() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.TestChangingInt")); AddVirtualTag(dbName, "VT-1", "EQ-1", "speed-rpm", "SCRIPT-1"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: true); result.Applied.ShouldBeFalse(); result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed-rpm"); using var db = UnsTreeTestDb.CreateNamed(dbName); db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue(); db.Tags.Any(t => t.Name == "speed-rpm").ShouldBeFalse(); } // ----- Case 7: scope ----- /// A fleet-wide (null) scope sweeps every equipment's relays. [Fact] public async Task Fleet_wide_scope_converts_all_equipments() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddEquipment(dbName, "EQ-2", "m_002"); AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.A")); AddScript(dbName, "SCRIPT-2", Relay("TestMachine_020.B")); AddVirtualTag(dbName, "VT-1", "EQ-1", "a", "SCRIPT-1"); AddVirtualTag(dbName, "VT-2", "EQ-2", "b", "SCRIPT-2"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync(null, dryRun: false); result.Converted.Count.ShouldBe(2); using var db = UnsTreeTestDb.CreateNamed(dbName); db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "a").ShouldBeTrue(); db.Tags.Any(t => t.EquipmentId == "EQ-2" && t.Name == "b").ShouldBeTrue(); } /// A scoped (single-equipment) call converts only that equipment's relay. [Fact] public async Task Scoped_call_converts_only_target_equipment() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddEquipment(dbName, "EQ-2", "m_002"); AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.A")); AddScript(dbName, "SCRIPT-2", Relay("TestMachine_020.B")); AddVirtualTag(dbName, "VT-1", "EQ-1", "a", "SCRIPT-1"); AddVirtualTag(dbName, "VT-2", "EQ-2", "b", "SCRIPT-2"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Converted.ShouldHaveSingleItem(i => i.EquipmentId == "EQ-1"); using var db = UnsTreeTestDb.CreateNamed(dbName); db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "a").ShouldBeTrue(); db.VirtualTags.Any(v => v.VirtualTagId == "VT-2").ShouldBeTrue(); db.Tags.Any(t => t.EquipmentId == "EQ-2").ShouldBeFalse(); } // ----- Case 8: {{equip}} expansion ----- /// /// A relay using the {{equip}} token on an equipment with an existing alias Tag (so a base /// is derivable) is converted with the token expanded against that base. /// [Fact] public async Task Equip_token_relay_expands_against_derived_base() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddAliasTag(dbName, "TAG-EXISTING", "EQ-1", "other", "TestMachine_020.Other"); AddScript(dbName, "SCRIPT-1", Relay("{{equip}}.Speed")); AddVirtualTag(dbName, "VT-1", "EQ-1", "speed", "SCRIPT-1"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); var item = result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed"); item.FullName.ShouldBe("TestMachine_020.Speed"); using var db = UnsTreeTestDb.CreateNamed(dbName); var tag = db.Tags.Single(t => t.Name == "speed"); using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig); doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Speed"); } /// /// A relay using the {{equip}} token on an equipment with no derivable base (no existing /// alias tags) is skipped with a reason mentioning the equipment-relative base. /// [Fact] public async Task Equip_token_relay_skipped_when_no_derivable_base() { var dbName = SeedCluster(); AddEquipment(dbName, "EQ-1", "m_001"); AddScript(dbName, "SCRIPT-1", Relay("{{equip}}.Speed")); AddVirtualTag(dbName, "VT-1", "EQ-1", "speed", "SCRIPT-1"); var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); result.Converted.ShouldBeEmpty(); var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "speed"); skip.Reason.ShouldContain("base", Case.Insensitive); using var db = UnsTreeTestDb.CreateNamed(dbName); db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); db.Tags.Any(t => t.Name == "speed").ShouldBeFalse(); } }