From 943bc5f709cc59a8b086322f9967d69f5b40d98c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 21:32:43 -0400 Subject: [PATCH] feat(adminui): ConvertRelayVirtualTagsToAliasesAsync (relay VTag -> alias Tag) --- .../Uns/IUnsTreeService.cs | 11 + .../Uns/RelayConversion.cs | 23 + .../Uns/UnsTreeService.cs | 161 ++++++ .../Uns/UnsTreeServiceRelayConverterTests.cs | 517 ++++++++++++++++++ 4 files changed, 712 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RelayConversion.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index c47d84e6..8fd7004d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -554,4 +554,15 @@ public interface IUnsTreeService /// A token to cancel the operation. /// Success, a concurrency failure, or a delete-failed failure. Task DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default); + + /// Convert pure-relay VirtualTags (body == return ctx.GetTag("X").Value;) to Galaxy alias + /// Tags. null = fleet-wide; otherwise scoped to that equipment. + /// = true previews without mutating. FleetAdmin-gated at the call site. + /// Operates on the editable config (does not publish). + /// The equipment to scope to; null sweeps every equipment. + /// true previews the conversion without mutating the config. + /// A token to cancel the operation. + /// The per-relay converted and skipped lists, plus whether the pass was applied. + Task ConvertRelayVirtualTagsToAliasesAsync( + string? equipmentId, bool dryRun, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RelayConversion.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RelayConversion.cs new file mode 100644 index 00000000..cf1ce6ed --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RelayConversion.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// One relay VirtualTag that will/would become an alias Tag. +/// The owning equipment whose relay virtual tag is converted. +/// The relay virtual tag's name (becomes the alias tag's name). +/// The resolved Galaxy reference the alias surfaces (any {{equip}} token expanded). +/// The OPC UA built-in type name carried over from the virtual tag. +public sealed record RelayConversionItem(string EquipmentId, string VirtualTagName, string FullName, string DataType); + +/// One relay VirtualTag that cannot be converted, with the reason. +/// The owning equipment whose virtual tag was skipped. +/// The skipped virtual tag's name. +/// A human-readable explanation of why the virtual tag was not converted. +public sealed record RelayConversionSkip(string EquipmentId, string VirtualTagName, string Reason); + +/// Outcome of a (dry-run or applied) conversion pass. +/// The relay virtual tags that were (or, on dry-run, would be) converted to alias tags. +/// The candidate virtual tags that could not be converted, each with a reason. +/// true when the pass mutated the config; false for a dry-run preview. +public sealed record RelayConversionResult( + IReadOnlyList Converted, + IReadOnlyList Skipped, + bool Applied); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 25512183..162255fb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -1032,6 +1032,167 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } + /// + public async Task ConvertRelayVirtualTagsToAliasesAsync( + string? equipmentId, bool dryRun, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // Candidate relay virtual tags: the whole draft, or one equipment when scoped. + var candidates = await db.VirtualTags + .Where(v => equipmentId == null || v.EquipmentId == equipmentId) + .OrderBy(v => v.EquipmentId).ThenBy(v => v.Name) + .ToListAsync(ct); + + // Source-by-id for every script a candidate references. A small map keeps the per-vtag relay + // parse a dictionary lookup rather than a query. + var scriptIds = candidates.Select(v => v.ScriptId).Distinct().ToList(); + var scripts = (await db.Scripts.Where(s => scriptIds.Contains(s.ScriptId)).ToListAsync(ct)) + .ToDictionary(s => s.ScriptId, s => s, StringComparer.Ordinal); + + var converted = new List(); + var skipped = new List(); + var aliasTagsToAdd = new List(); + var toRemoveVtags = new List(); + + // Per-equipment caches so a sweep resolves each equipment's cluster, gateway, and (lazily) its + // derivable {{equip}} base only once. + var clusterCache = new Dictionary(StringComparer.Ordinal); + var gatewayCache = new Dictionary(StringComparer.Ordinal); + var equipBaseCache = new Dictionary(StringComparer.Ordinal); + + foreach (var vtag in candidates) + { + var source = scripts.GetValueOrDefault(vtag.ScriptId)?.SourceCode; + if (!EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef) || rawRef is null) + { + skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name, + "Script body is not a pure relay (return ctx.GetTag(\"…\").Value;) — convert manually.")); + continue; + } + + if (vtag.Historize) + { + skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name, + "Virtual tag is historized — a Tag has no historize column; convert manually.")); + continue; + } + + // Resolve (and cache) the equipment's cluster and its first Galaxy gateway. + if (!gatewayCache.TryGetValue(vtag.EquipmentId, out var gatewayId)) + { + if (!clusterCache.TryGetValue(vtag.EquipmentId, out var cluster)) + { + cluster = await ResolveEquipmentClusterAsync(db, vtag.EquipmentId, ct); + clusterCache[vtag.EquipmentId] = cluster; + } + + gatewayId = cluster is null + ? null + : await db.DriverInstances + .Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway") + .OrderBy(d => d.DriverInstanceId) + .Select(d => d.DriverInstanceId) + .FirstOrDefaultAsync(ct); + gatewayCache[vtag.EquipmentId] = gatewayId; + } + + if (gatewayId is null) + { + skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name, + "There is no Galaxy gateway in this equipment's cluster to bind the alias to.")); + continue; + } + + // Resolve the alias FullName, expanding the {{equip}} token against the equipment's derivable + // tag base (its existing Galaxy-alias tag FullNames) when present. + string fullName; + if (EquipmentScriptPaths.ContainsEquipToken(rawRef)) + { + if (!equipBaseCache.TryGetValue(vtag.EquipmentId, out var equipBase)) + { + var configs = await db.Tags + .Where(t => t.EquipmentId == vtag.EquipmentId && t.DriverInstanceId == gatewayId) + .Select(t => t.TagConfig) + .ToListAsync(ct); + equipBase = EquipmentScriptPaths.DeriveEquipmentBase(configs.Select(ExtractTagConfigFullName)); + equipBaseCache[vtag.EquipmentId] = equipBase; + } + + if (string.IsNullOrEmpty(equipBase)) + { + skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name, + $"Relay uses the equipment-relative {EquipmentScriptPaths.EquipToken} token but the equipment " + + "has no derivable tag base (add at least one Galaxy alias tag first).")); + continue; + } + + var expandedSource = EquipmentScriptPaths.SubstituteEquipmentToken(source!, equipBase); + if (!EquipmentScriptPaths.TryParseRelayBody(expandedSource, out var concrete) || concrete is null) + { + skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name, + $"Could not expand the {EquipmentScriptPaths.EquipToken} token into a concrete Galaxy reference.")); + continue; + } + + fullName = concrete; + } + else + { + fullName = rawRef; + } + + converted.Add(new RelayConversionItem(vtag.EquipmentId, vtag.Name, fullName, vtag.DataType)); + + if (!dryRun) + { + aliasTagsToAdd.Add(BuildAliasTag( + vtag.EquipmentId, + new AliasTagInput(NewTagId(), vtag.Name, gatewayId, vtag.DataType, TagAccessLevel.Read, fullName))); + toRemoveVtags.Add(vtag); + } + } + + if (!dryRun && toRemoveVtags.Count > 0) + { + // Snapshot every (VirtualTagId, ScriptId) pair in the whole draft BEFORE staging removals, so + // the orphan check sees a stable view (EF still reports a RemoveRange'd entity as present in a + // LINQ-to-entities query until SaveChanges). A script stays alive if any virtual tag NOT being + // removed still binds it, or any ScriptedAlarm uses it as a predicate. + var allVtagPairs = await db.VirtualTags + .Select(v => new { v.VirtualTagId, v.ScriptId }) + .ToListAsync(ct); + var alarmScriptIds = (await db.ScriptedAlarms.Select(a => a.PredicateScriptId).ToListAsync(ct)) + .ToHashSet(StringComparer.Ordinal); + var removedVtagIds = toRemoveVtags.Select(v => v.VirtualTagId).ToHashSet(StringComparer.Ordinal); + + db.Tags.AddRange(aliasTagsToAdd); + db.VirtualTags.RemoveRange(toRemoveVtags); + + foreach (var scriptId in toRemoveVtags.Select(v => v.ScriptId).Distinct(StringComparer.Ordinal)) + { + var stillUsedByVtag = allVtagPairs.Any(p => p.ScriptId == scriptId && !removedVtagIds.Contains(p.VirtualTagId)); + var stillUsedByAlarm = alarmScriptIds.Contains(scriptId); + if (!stillUsedByVtag && !stillUsedByAlarm && scripts.TryGetValue(scriptId, out var s)) + { + db.Scripts.Remove(s); + } + } + + await db.SaveChangesAsync(ct); + } + + return new RelayConversionResult(converted, skipped, Applied: !dryRun); + } + + /// + /// Mints a unique alias TagId following the codebase's only id-minting convention — the + /// EQ- equipment-id scheme of "<prefix>-" + Guid.ToString("N")[..12] (decision #125). + /// No TAG- minting helper existed before this converter, so a fresh GUID-derived id is used: + /// it cannot collide with a removed relay's namespace and needs no read-back uniqueness check. + /// + private static string NewTagId() => $"TAG-{Guid.NewGuid().ToString("N")[..12]}"; + /// public async Task> LoadScriptsAsync(CancellationToken ct = default) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs new file mode 100644 index 00000000..f87893cf --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs @@ -0,0 +1,517 @@ +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(); + } +}