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