feat(adminui): ConvertRelayVirtualTagsToAliasesAsync (relay VTag -> alias Tag)

This commit is contained in:
Joseph Doherty
2026-06-11 21:32:43 -04:00
parent fe068652b3
commit 943bc5f709
4 changed files with 712 additions and 0 deletions
@@ -554,4 +554,15 @@ public interface IUnsTreeService
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>Convert pure-relay VirtualTags (body == return ctx.GetTag("X").Value;) to Galaxy alias
/// Tags. <paramref name="equipmentId"/> null = fleet-wide; otherwise scoped to that equipment.
/// <paramref name="dryRun"/> = true previews without mutating. FleetAdmin-gated at the call site.
/// Operates on the editable config (does not publish).</summary>
/// <param name="equipmentId">The equipment to scope to; <c>null</c> sweeps every equipment.</param>
/// <param name="dryRun"><c>true</c> previews the conversion without mutating the config.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The per-relay converted and skipped lists, plus whether the pass was applied.</returns>
Task<RelayConversionResult> ConvertRelayVirtualTagsToAliasesAsync(
string? equipmentId, bool dryRun, CancellationToken ct = default);
}
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>One relay VirtualTag that will/would become an alias Tag.</summary>
/// <param name="EquipmentId">The owning equipment whose relay virtual tag is converted.</param>
/// <param name="VirtualTagName">The relay virtual tag's name (becomes the alias tag's name).</param>
/// <param name="FullName">The resolved Galaxy reference the alias surfaces (any <c>{{equip}}</c> token expanded).</param>
/// <param name="DataType">The OPC UA built-in type name carried over from the virtual tag.</param>
public sealed record RelayConversionItem(string EquipmentId, string VirtualTagName, string FullName, string DataType);
/// <summary>One relay VirtualTag that cannot be converted, with the reason.</summary>
/// <param name="EquipmentId">The owning equipment whose virtual tag was skipped.</param>
/// <param name="VirtualTagName">The skipped virtual tag's name.</param>
/// <param name="Reason">A human-readable explanation of why the virtual tag was not converted.</param>
public sealed record RelayConversionSkip(string EquipmentId, string VirtualTagName, string Reason);
/// <summary>Outcome of a (dry-run or applied) conversion pass.</summary>
/// <param name="Converted">The relay virtual tags that were (or, on dry-run, would be) converted to alias tags.</param>
/// <param name="Skipped">The candidate virtual tags that could not be converted, each with a reason.</param>
/// <param name="Applied"><c>true</c> when the pass mutated the config; <c>false</c> for a dry-run preview.</param>
public sealed record RelayConversionResult(
IReadOnlyList<RelayConversionItem> Converted,
IReadOnlyList<RelayConversionSkip> Skipped,
bool Applied);
@@ -1032,6 +1032,167 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <inheritdoc />
public async Task<RelayConversionResult> 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<RelayConversionItem>();
var skipped = new List<RelayConversionSkip>();
var aliasTagsToAdd = new List<Tag>();
var toRemoveVtags = new List<VirtualTag>();
// Per-equipment caches so a sweep resolves each equipment's cluster, gateway, and (lazily) its
// derivable {{equip}} base only once.
var clusterCache = new Dictionary<string, string?>(StringComparer.Ordinal);
var gatewayCache = new Dictionary<string, string?>(StringComparer.Ordinal);
var equipBaseCache = new Dictionary<string, string?>(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);
}
/// <summary>
/// Mints a unique alias <c>TagId</c> following the codebase's only id-minting convention — the
/// <c>EQ-</c> equipment-id scheme of <c>"&lt;prefix&gt;-" + Guid.ToString("N")[..12]</c> (decision #125).
/// No <c>TAG-</c> 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.
/// </summary>
private static string NewTagId() => $"TAG-{Guid.NewGuid().ToString("N")[..12]}";
/// <inheritdoc />
public async Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default)
{
@@ -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;
/// <summary>
/// Verifies <see cref="UnsTreeService.ConvertRelayVirtualTagsToAliasesAsync"/>: the converter that
/// rewrites pure-relay VirtualTags (body == <c>return ctx.GetTag("X").Value;</c>) into equivalent
/// Galaxy alias <see cref="Tag"/> rows, deletes the relay VirtualTag, and prunes the orphaned
/// <see cref="Script"/> when nothing else references it. Covers conversion, the skip reasons
/// (non-relay, historized, no gateway, unresolvable <c>{{equip}}</c>), dry-run, scope, shared-script
/// retention/pruning, and the <c>{{equip}}</c> expansion path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceRelayConverterTests
{
private const string ClusterId = "MAIN";
private const string GatewayDriverId = "DRV-GALAXY";
private const string ModbusDriverId = "DRV-MODBUS";
/// <summary>
/// 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 <see cref="AddEquipment"/>.
/// </summary>
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;
}
/// <summary>Adds an equipment under LINE-1 in the seeded cluster.</summary>
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();
}
/// <summary>Adds a script row with the given source.</summary>
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();
}
/// <summary>Adds a virtual tag bound to a script on an equipment.</summary>
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();
}
/// <summary>Adds an existing Galaxy alias Tag on an equipment (used for {{equip}} base derivation).</summary>
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) -----
/// <summary>
/// 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.
/// </summary>
[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 -----
/// <summary>A computed body (not a pure relay) is skipped; the VirtualTag and Script remain.</summary>
[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 -----
/// <summary>
/// One script backs two relay virtual tags. A fleet-wide apply converts both, so the now-orphan
/// script is deleted.
/// </summary>
[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 -----
/// <summary>
/// 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.
/// </summary>
[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 -----
/// <summary>
/// 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.
/// </summary>
[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 -----
/// <summary>
/// A relay's script is also a ScriptedAlarm predicate. Converting the relay must leave the script
/// in place because the alarm still references it.
/// </summary>
[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 -----
/// <summary>A historized relay virtual tag is skipped (Tag has no historize column) and unchanged.</summary>
[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 -----
/// <summary>A relay on an equipment whose cluster has no Galaxy gateway is skipped, unchanged.</summary>
[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 -----
/// <summary>A dry-run lists the convertible relay but mutates nothing; Applied == false.</summary>
[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 -----
/// <summary>A fleet-wide (null) scope sweeps every equipment's relays.</summary>
[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();
}
/// <summary>A scoped (single-equipment) call converts only that equipment's relay.</summary>
[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 -----
/// <summary>
/// A relay using the <c>{{equip}}</c> token on an equipment with an existing alias Tag (so a base
/// is derivable) is converted with the token expanded against that base.
/// </summary>
[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");
}
/// <summary>
/// A relay using the <c>{{equip}}</c> token on an equipment with no derivable base (no existing
/// alias tags) is skipped with a reason mentioning the equipment-relative base.
/// </summary>
[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();
}
}