refactor(adminui): strip alias/relay machinery from UnsTreeService + EquipmentPage; Galaxy tags use standard TagModal

This commit is contained in:
Joseph Doherty
2026-06-12 21:28:13 -04:00
parent ca2698949b
commit 5dfb797817
6 changed files with 6 additions and 1803 deletions
@@ -139,17 +139,7 @@ else
else if (_activeTab == "tags")
{
<div class="d-flex justify-content-end align-items-center gap-2 mb-2">
@if (_gateways.Count == 0)
{
<span class="text-muted small">No Galaxy gateway in this cluster</span>
}
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddAlias" disabled="@(_gateways.Count == 0)">Add alias (browse Galaxy)</button>
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
<AuthorizeView Policy="FleetAdmin">
<Authorized>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="PreviewConvertRelaysAsync" disabled="@(_gateways.Count == 0 || _convertBusy)">Convert relay virtual-tags…</button>
</Authorized>
</AuthorizeView>
</div>
@if (!string.IsNullOrWhiteSpace(_tagError))
{
@@ -167,7 +157,7 @@ else
{
<table class="table table-sm">
<thead>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th>Source</th><th class="text-end">Actions</th></tr>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr>
</thead>
<tbody>
@foreach (var t in _tags)
@@ -177,22 +167,8 @@ else
<td class="mono">@t.DriverInstanceId</td>
<td>@t.DataType</td>
<td>@t.AccessLevel</td>
<td>
@if (t.IsAlias)
{
<span class="badge bg-info me-1">alias</span>
<span class="mono small">@t.Source</span>
}
</td>
<td class="text-end">
@if (t.IsAlias)
{
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditAlias(t.TagId)">Edit</button>
}
else
{
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
}
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
</td>
</tr>
@@ -201,106 +177,9 @@ else
</table>
}
@if (!string.IsNullOrWhiteSpace(_convertMessage))
{
<div class="text-success small mb-2">@_convertMessage</div>
}
<AuthorizeView Policy="FleetAdmin">
<Authorized>
@if (!string.IsNullOrWhiteSpace(_convertError))
{
<div class="text-danger small mb-2">@_convertError</div>
}
@if (_convertPreview is not null)
{
<section class="panel rise mt-2">
<div class="panel-head d-flex justify-content-between align-items-center">
<span>Convert relay virtual-tags to aliases</span>
<button type="button" class="btn btn-sm btn-link" @onclick="@(() => { _convertPreview = null; _convertConfirming = false; })">Close</button>
</div>
<div style="padding:1rem">
<h6 class="text-muted">Will convert (@_convertPreview.Converted.Count)</h6>
@if (_convertPreview.Converted.Count == 0)
{
<p class="text-muted small mb-3">No relay virtual-tags to convert.</p>
}
else
{
<table class="table table-sm mb-3">
<thead><tr><th>Virtual tag</th><th>Full name</th><th>Data type</th></tr></thead>
<tbody>
@foreach (var c in _convertPreview.Converted)
{
<tr @key="c.FullName">
<td>@c.VirtualTagName</td>
<td class="mono">@c.FullName</td>
<td>@c.DataType</td>
</tr>
}
</tbody>
</table>
}
<h6 class="text-muted">Skipped (@_convertPreview.Skipped.Count)</h6>
@if (_convertPreview.Skipped.Count == 0)
{
<p class="text-muted small mb-0">Nothing skipped.</p>
}
else
{
<table class="table table-sm mb-0">
<thead><tr><th>Virtual tag</th><th>Reason</th></tr></thead>
<tbody>
@foreach (var s in _convertPreview.Skipped)
{
<tr @key="s.VirtualTagName">
<td>@s.VirtualTagName</td>
<td>@s.Reason</td>
</tr>
}
</tbody>
</table>
}
<div class="mt-3 d-flex gap-2 align-items-center">
@if (_convertPreview.Converted.Count > 0)
{
@if (!_convertConfirming)
{
<button type="button" class="btn btn-primary btn-sm" @onclick="() => _convertConfirming = true" disabled="@_convertBusy">
Apply…
</button>
}
else
{
<span class="small">Convert @_convertPreview.Converted.Count relay virtual-tag(s)?</span>
<button type="button" class="btn btn-danger btn-sm" @onclick="ApplyConvertRelaysAsync" disabled="@_convertBusy">
@if (_convertBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Yes, apply
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="() => _convertConfirming = false" disabled="@_convertBusy">Cancel</button>
}
}
@if (!_convertConfirming)
{
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="@(() => { _convertPreview = null; _convertConfirming = false; })" disabled="@_convertBusy">Close</button>
}
</div>
</div>
</section>
}
</Authorized>
</AuthorizeView>
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
<AliasTagModal Visible="_aliasModalVisible" IsNew="_aliasModalIsNew" EquipmentId="@EquipmentId"
Existing="_aliasModalExisting" Gateways="_gateways"
OnSaved="OnAliasSavedAsync" OnCancel="@(() => { _aliasModalVisible = false; })" />
}
else if (_activeTab == "vtags")
{
@@ -421,24 +300,6 @@ else
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagDriverOptions = Array.Empty<(string, string, string)>();
// --- Alias-tag modal state (Galaxy alias = an equipment Tag bound to a GalaxyMxGateway driver). The
// gateways are reloaded alongside _tags so the Add-alias button can disable itself when none exist. ---
private IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)> _gateways
= Array.Empty<(string, string, string)>();
private bool _aliasModalVisible;
private bool _aliasModalIsNew;
private AliasTagEditDto? _aliasModalExisting;
// --- Relay→alias converter (per-equipment). _convertPreview holds the dry-run result while the
// inline preview panel is open; null = panel closed. _convertMessage is a brief post-apply summary.
// _convertError surfaces exceptions so a service failure doesn't crash the circuit. _convertConfirming
// guards the Apply button (two-step confirm, mirrors the fleet page). ---
private RelayConversionResult? _convertPreview;
private bool _convertBusy;
private bool _convertConfirming;
private string? _convertMessage;
private string? _convertError;
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
private string? _vtagError;
@@ -478,8 +339,6 @@ else
private async Task ReloadTagsAsync()
{
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
// Also refresh the candidate Galaxy gateways so the Add-alias affordance reflects the cluster.
_gateways = await Svc.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId!);
}
private async Task OpenAddTag()
@@ -520,70 +379,6 @@ else
else { _tagError = r.Error; }
}
// --- Alias-tag handlers (mirror the tag handlers; aliases live in the same _tags list + delete the
// same way, so only the create/edit modal differs — it embeds the Galaxy live-browse picker). ---
private async Task OpenAddAlias()
{
_tagError = null;
_gateways = await Svc.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId!);
_aliasModalIsNew = true;
_aliasModalExisting = null;
_aliasModalVisible = true;
}
private async Task OpenEditAlias(string tagId)
{
_tagError = null;
var dto = await Svc.LoadAliasTagAsync(tagId);
if (dto is null) { _tagError = "That alias no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
_aliasModalIsNew = false;
_aliasModalExisting = dto;
_aliasModalVisible = true;
}
private async Task OnAliasSavedAsync()
{
_aliasModalVisible = false;
await ReloadTagsAsync();
}
// --- Relay→alias converter handlers (per-equipment, scoped to EquipmentId). Dry-run first to fill
// the inline preview panel; Apply mutates the draft then reloads the tags + virtual-tags lists so
// the converted vtags vanish and the new aliases appear. ---
private async Task PreviewConvertRelaysAsync()
{
_convertMessage = null;
_convertError = null;
_convertConfirming = false;
_convertBusy = true;
try
{
_convertPreview = await Svc.ConvertRelayVirtualTagsToAliasesAsync(EquipmentId!, dryRun: true);
}
catch (Exception ex) { _convertPreview = null; _convertError = ex.Message; }
finally { _convertBusy = false; }
}
private async Task ApplyConvertRelaysAsync()
{
_convertBusy = true;
_convertError = null;
try
{
var r = await Svc.ConvertRelayVirtualTagsToAliasesAsync(EquipmentId!, dryRun: false);
_convertPreview = null;
_convertConfirming = false;
_convertMessage = $"Converted {r.Converted.Count}, skipped {r.Skipped.Count}.";
// The converted virtual tags become aliases, so both lists change.
await ReloadTagsAsync();
await ReloadVirtualTagsAsync();
}
catch (Exception ex) { _convertError = ex.Message; _convertConfirming = false; }
finally { _convertBusy = false; }
}
// --- Virtual Tags tab handlers ---
private async Task ReloadVirtualTagsAsync()
@@ -686,11 +481,6 @@ else
_tags = null;
_vtags = null;
_alarms = null;
// Drop any open relay-conversion preview/summary/confirm so it can't leak across equipment changes.
_convertPreview = null;
_convertMessage = null;
_convertError = null;
_convertConfirming = false;
if (!IsNew)
{
_equipment = await Svc.LoadEquipmentAsync(EquipmentId!);
@@ -3,13 +3,9 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>A tag row for the equipment page's Tags tab table — display columns plus the id used to
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).
/// Galaxy alias rows (tags bound to a <c>GalaxyMxGateway</c> driver) carry <c>IsAlias = true</c> and a
/// <c>Source</c> of <c>"galaxy:&lt;FullName&gt;"</c> taken from the tag's <c>TagConfig</c>; ordinary
/// equipment tags carry <c>IsAlias = false</c> and a <c>null</c> <c>Source</c>.</summary>
/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path).</summary>
public sealed record EquipmentTagRow(
string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel,
bool IsAlias = false, string? Source = null);
string TagId, string Name, string DriverInstanceId, string DataType, TagAccessLevel AccessLevel);
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
@@ -175,12 +175,6 @@ public interface IUnsTreeService
/// <returns>The tag's edit projection, or <c>null</c> when missing.</returns>
Task<TagEditDto?> LoadTagAsync(string tagId, CancellationToken ct = default);
/// <summary>Load a Galaxy alias tag for editing (FullName parsed from TagConfig). Null if not found.</summary>
/// <param name="tagId">The alias tag to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The alias tag's edit projection, or <c>null</c> when missing.</returns>
Task<AliasTagEditDto?> LoadAliasTagAsync(string tagId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment-bound virtual tag projected for editing, or <c>null</c> if it no longer
/// exists. Reads untracked and captures the current concurrency token for last-write-wins saves.
@@ -350,13 +344,6 @@ public interface IUnsTreeService
/// where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>Galaxy gateway driver instances (DriverType "GalaxyMxGateway") in the equipment's
/// cluster, for the alias address picker. Tuple = (DriverInstanceId, Display, DriverConfig).
/// <c>Display</c> is intentionally <c>"{DriverInstanceId} — {Name}"</c> (no DriverType suffix)
/// because every returned driver is a Galaxy gateway — do not add the type suffix.</summary>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
/// the tree only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
@@ -385,33 +372,6 @@ public interface IUnsTreeService
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new Galaxy alias tag on an equipment: an ordinary equipment-bound <c>Tag</c> bound to a
/// <c>GalaxyMxGateway</c> driver, with <c>FolderPath</c> null and a <c>{"FullName":…}</c> TagConfig
/// carrying the picked Galaxy reference. Fails on a duplicate <c>TagId</c>, an empty Galaxy reference,
/// an unknown equipment, a driver that is not a Galaxy gateway in the equipment's cluster, or a name
/// already used on the equipment.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable alias fields (id, name, gateway, type, access, reference).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures.</returns>
Task<UnsMutationResult> CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default);
/// <summary>
/// Updates a Galaxy alias tag's gateway binding, name, data type, access level, and Galaxy reference
/// (the <c>{"FullName":…}</c> TagConfig). The owning <c>EquipmentId</c> and the null <c>FolderPath</c>
/// are preserved. Re-runs the empty-reference and Galaxy-gateway-in-cluster guards against the alias's
/// existing equipment, and enforces name uniqueness on that equipment excluding this tag. Uses
/// last-write-wins optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The alias tag to update.</param>
/// <param name="input">The new operator-editable alias fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
@@ -554,15 +514,4 @@ 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);
}
@@ -86,35 +86,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
public async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// Left-join each tag to its driver so we can tell Galaxy aliases apart while still surfacing a
// tag whose driver row is missing (it is simply treated as a non-alias). EF can't parse the
// TagConfig JSON in-query, so we materialise then map IsAlias/Source in memory.
var rows = await db.Tags.AsNoTracking()
return await db.Tags.AsNoTracking()
.Where(t => t.EquipmentId == equipmentId)
.OrderBy(t => t.Name)
.GroupJoin(db.DriverInstances.AsNoTracking(), t => t.DriverInstanceId, d => d.DriverInstanceId,
(t, ds) => new { Tag = t, Drivers = ds })
.SelectMany(x => x.Drivers.DefaultIfEmpty(),
(x, d) => new
{
x.Tag.TagId,
x.Tag.Name,
x.Tag.DriverInstanceId,
x.Tag.DataType,
x.Tag.AccessLevel,
DriverType = d != null ? d.DriverType : null,
x.Tag.TagConfig,
})
.Select(t => new EquipmentTagRow(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel))
.ToListAsync(ct);
return rows.Select(r =>
{
var isAlias = r.DriverType == "GalaxyMxGateway";
var fullName = isAlias ? ExtractTagConfigFullName(r.TagConfig) : null;
var source = fullName is not null ? $"galaxy:{fullName}" : null;
return new EquipmentTagRow(r.TagId, r.Name, r.DriverInstanceId, r.DataType, r.AccessLevel, isAlias, source);
}).ToList();
}
/// <inheritdoc />
@@ -204,16 +180,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<AliasTagEditDto?> LoadAliasTagAsync(string tagId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var t = await db.Tags.AsNoTracking().FirstOrDefaultAsync(x => x.TagId == tagId, ct);
if (t is null) return null;
return new AliasTagEditDto(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel,
ExtractTagConfigFullName(t.TagConfig) ?? string.Empty, t.RowVersion);
}
/// <inheritdoc />
public async Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default)
{
@@ -774,29 +740,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (cluster is null)
{
return Array.Empty<(string, string, string)>();
}
var gateways = await db.DriverInstances
.Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway")
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverConfig })
.ToListAsync(ct);
return gateways
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverConfig))
.ToList();
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateTagAsync(
string equipmentId,
@@ -903,103 +846,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateAliasTagAsync(
string equipmentId,
AliasTagInput input,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
{
return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
}
if (string.IsNullOrWhiteSpace(input.FullName))
{
return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
}
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
{
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
}
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
db.Tags.Add(BuildAliasTag(equipmentId, input));
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateAliasTagAsync(
string tagId,
AliasTagInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
if (string.IsNullOrWhiteSpace(input.FullName))
{
return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
}
var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(
t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
// EquipmentId and FolderPath (null) are preserved — alias tags are always equipment-bound.
entity.DriverInstanceId = input.DriverInstanceId;
entity.Name = input.Name;
entity.DataType = input.DataType;
entity.AccessLevel = input.AccessLevel;
entity.FolderPath = null;
entity.WriteIdempotent = false;
entity.PollGroupId = null;
entity.TagConfig = BuildAliasTagConfig(input.FullName);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this tag while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteTagAsync(
string tagId,
@@ -1032,195 +878,6 @@ 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);
// Pre-load all existing (EquipmentId, Name) tag pairs so per-candidate name-collision checks
// are O(1) HashSet lookups rather than per-row DB queries. The set reflects the state BEFORE
// this batch; intra-batch alias-vs-alias collisions per equipment cannot occur because vtag
// names are unique within an equipment's VirtualTags table.
var existingTagNames = (await db.Tags
.Where(t => t.EquipmentId != null)
.Select(t => new { t.EquipmentId, t.Name })
.ToListAsync(ct))
.Select(t => new EquipmentName(t.EquipmentId!, t.Name))
.ToHashSet(EquipmentNameComparer.Instance);
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.Enabled)
{
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
"Virtual tag is disabled — convert manually if intended."));
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;
}
// Check for a name collision with an already-existing Tag on this equipment. If a Tag with
// the same (EquipmentId, Name) exists, converting would violate the unique index and abort
// the whole SaveChangesAsync — skip this vtag and report it instead.
if (existingTagNames.Contains(new EquipmentName(vtag.EquipmentId, vtag.Name)))
{
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
$"A tag named '{vtag.Name}' already exists on this equipment — convert manually if intended."));
continue;
}
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 id-minting convention —
/// <c>"&lt;prefix&gt;-" + Guid.ToString("N")[..12]</c> (decision #125). A collision with another
/// in-flight id is negligible given the 48-bit GUID prefix; the <c>TagId</c> unique index is the
/// backstop.
/// </summary>
private static string NewTagId() => $"TAG-{Guid.NewGuid().ToString("N")[..12]}";
/// <inheritdoc />
public async Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default)
{
@@ -1496,32 +1153,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <summary>
/// Extracts the <c>FullName</c> string from a tag's <c>TagConfig</c> JSON (the Galaxy reference an
/// alias surfaces), or <c>null</c> when the config is empty, not a JSON object, lacks a string
/// <c>FullName</c>, or is malformed. A small local copy mirrors the composer's own extraction —
/// consistent with this codebase, where the composer and validator each keep their own.
/// </summary>
private static string? ExtractTagConfigFullName(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig))
{
return null;
}
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
/// <summary>
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
@@ -1586,89 +1217,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return null;
}
/// <summary>
/// Galaxy-aware driver guard for aliases: the driver must be a Galaxy gateway
/// (<c>DriverType == "GalaxyMxGateway"</c>) in the equipment's cluster. Distinct from
/// <see cref="CheckTagDriverGuardAsync"/>, which requires an Equipment-kind namespace and would reject
/// the gateway. Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
/// </summary>
private static async Task<UnsMutationResult?> CheckAliasDriverGuardAsync(
OtOpcUaConfigDbContext db,
string driverInstanceId,
string? equipmentCluster,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(driverInstanceId))
return new UnsMutationResult(false, "An alias must be bound to a Galaxy gateway.");
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (driver is null)
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
}
if (driver.DriverType != "GalaxyMxGateway")
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway.");
}
if (driver.ClusterId != equipmentCluster)
{
return new UnsMutationResult(
false,
$"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'.");
}
return null;
}
/// <summary>
/// Serialises a Galaxy reference into the alias TagConfig envelope <c>{"FullName":"&lt;ref&gt;"}</c>.
/// </summary>
private static string BuildAliasTagConfig(string fullName) =>
System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string> { ["FullName"] = fullName });
/// <summary>
/// Builds the alias <see cref="Tag"/> entity for an equipment: a Galaxy-gateway-bound, equipment-scoped
/// tag with a null <c>FolderPath</c>, no poll group, non-idempotent writes, and the
/// <c>{"FullName":…}</c> TagConfig. A pure builder — reused by the relay→alias converter so create and
/// conversion produce byte-identical rows.
/// </summary>
private static Tag BuildAliasTag(string equipmentId, AliasTagInput input) => new()
{
TagId = input.TagId,
DriverInstanceId = input.DriverInstanceId,
EquipmentId = equipmentId,
Name = input.Name,
FolderPath = null,
DataType = input.DataType,
AccessLevel = input.AccessLevel,
WriteIdempotent = false,
PollGroupId = null,
TagConfig = BuildAliasTagConfig(input.FullName),
};
/// <summary>Lightweight key for the pre-loaded (EquipmentId, Name) tag-name collision set.</summary>
private readonly record struct EquipmentName(string EquipmentId, string Name);
/// <summary>
/// Equality comparer for <see cref="EquipmentName"/> using ordinal string comparison on both fields,
/// matching the database unique index semantics for <c>(EquipmentId, Name)</c>.
/// </summary>
private sealed class EquipmentNameComparer : IEqualityComparer<EquipmentName>
{
public static readonly EquipmentNameComparer Instance = new();
public bool Equals(EquipmentName x, EquipmentName y) =>
StringComparer.Ordinal.Equals(x.EquipmentId, y.EquipmentId) &&
StringComparer.Ordinal.Equals(x.Name, y.Name);
public int GetHashCode(EquipmentName obj) =>
HashCode.Combine(
StringComparer.Ordinal.GetHashCode(obj.EquipmentId),
StringComparer.Ordinal.GetHashCode(obj.Name));
}
/// <summary>
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
/// Policy:
@@ -1,466 +0,0 @@
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 the read-side surface for Galaxy alias tags: that
/// <see cref="UnsTreeService.LoadGalaxyGatewaysForEquipmentAsync"/> finds the
/// <c>GalaxyMxGateway</c> drivers in the equipment's cluster (and only those), and that
/// <see cref="UnsTreeService.LoadTagsForEquipmentAsync"/> flags alias tags (those bound to a Galaxy
/// gateway) with <c>IsAlias = true</c> and a <c>Source</c> derived from the tag's <c>TagConfig</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceAliasTagTests
{
private const string ClusterId = "MAIN";
private const string EquipmentId = "EQ-ALIAS-1";
private const string GatewayDriverId = "DRV-GALAXY";
private const string ModbusDriverId = "DRV-MODBUS";
/// <summary>
/// Seeds a cluster with: a SystemPlatform namespace + GalaxyMxGateway driver, an Equipment-kind
/// namespace + Modbus driver, and an area→line→equipment path. Returns the InMemory db name.
/// </summary>
private static string SeedCluster(bool withGalaxyGateway = true)
{
var dbName = $"uns-alias-{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.Equipment.Add(new Equipment
{
EquipmentId = EquipmentId,
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = "machine-1",
MachineCode = "machine_001",
});
// SystemPlatform namespace hosting the Galaxy gateway.
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 = "{\"Galaxy\":{}}",
});
}
// Equipment-kind namespace hosting an ordinary (non-Galaxy) driver.
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>
/// The gateway lookup returns exactly the GalaxyMxGateway driver (with its id, a display string,
/// and its DriverConfig) and never the cluster's Modbus driver.
/// </summary>
[Fact]
public async Task LoadGalaxyGatewaysForEquipment_returns_only_galaxy_gateway()
{
var dbName = SeedCluster();
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId);
gateways.Count.ShouldBe(1);
gateways[0].DriverInstanceId.ShouldBe(GatewayDriverId);
gateways[0].Display.ShouldContain(GatewayDriverId);
gateways[0].Display.ShouldContain("galaxy gateway");
gateways[0].DriverConfig.ShouldBe("{\"Galaxy\":{}}");
gateways.ShouldNotContain(g => g.DriverInstanceId == ModbusDriverId);
}
/// <summary>An equipment whose cluster has no GalaxyMxGateway driver yields an empty list.</summary>
[Fact]
public async Task LoadGalaxyGatewaysForEquipment_returns_empty_when_no_gateway()
{
var dbName = SeedCluster(withGalaxyGateway: false);
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var gateways = await service.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId);
gateways.ShouldBeEmpty();
}
/// <summary>
/// A tag bound to the Galaxy gateway is an alias (<c>IsAlias = true</c>, <c>Source</c> derived from
/// its TagConfig FullName); a tag bound to the Modbus driver is not (<c>IsAlias = false</c>,
/// <c>Source = null</c>).
/// </summary>
[Fact]
public async Task LoadTagsForEquipment_flags_galaxy_alias_rows()
{
var dbName = SeedCluster();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.Tags.Add(new Tag
{
TagId = "TAG-ALIAS",
DriverInstanceId = GatewayDriverId,
EquipmentId = EquipmentId,
Name = "aliased-speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}",
});
db.Tags.Add(new Tag
{
TagId = "TAG-NORMAL",
DriverInstanceId = ModbusDriverId,
EquipmentId = EquipmentId,
Name = "raw-speed",
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.SaveChanges();
}
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var rows = await service.LoadTagsForEquipmentAsync(EquipmentId);
var alias = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-ALIAS");
alias.IsAlias.ShouldBeTrue();
alias.Source.ShouldBe("galaxy:TestMachine_020.Speed");
var normal = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-NORMAL");
normal.IsAlias.ShouldBeFalse();
normal.Source.ShouldBeNull();
}
/// <summary>
/// A GalaxyMxGateway-bound tag whose TagConfig carries no <c>FullName</c> (e.g. <c>"{}"</c>) is
/// still an alias (<c>IsAlias = true</c>) but has a <c>null</c> Source — the display should not
/// show the literal <c>"galaxy:"</c> string.
/// </summary>
[Fact]
public async Task LoadTagsForEquipment_alias_with_no_FullName_has_null_Source()
{
var dbName = SeedCluster();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.Tags.Add(new Tag
{
TagId = "TAG-ALIAS-NONAME",
DriverInstanceId = GatewayDriverId,
EquipmentId = EquipmentId,
Name = "unconfigured-alias",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.SaveChanges();
}
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var rows = await service.LoadTagsForEquipmentAsync(EquipmentId);
var alias = rows.ShouldHaveSingleItem(r => r.TagId == "TAG-ALIAS-NONAME");
alias.IsAlias.ShouldBeTrue();
alias.Source.ShouldBeNull();
}
// ---- Write-side: CreateAliasTagAsync / UpdateAliasTagAsync (T5) ----
private const string SecondClusterId = "ALT";
private const string SecondGatewayDriverId = "DRV-GALAXY-ALT";
/// <summary>
/// Seeds a second cluster (no UNS hierarchy needed) carrying its own GalaxyMxGateway driver, so a
/// cross-cluster guard test can reference a gateway that is NOT in the equipment's cluster.
/// </summary>
private static void SeedSecondClusterGateway(string dbName)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.ServerClusters.Add(new ServerCluster
{
ClusterId = SecondClusterId,
Name = "Alt",
Enterprise = "zb",
Site = "krakow",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
});
db.Namespaces.Add(new Namespace
{
NamespaceId = "NS-SP-ALT",
ClusterId = SecondClusterId,
Kind = NamespaceKind.SystemPlatform,
NamespaceUri = "urn:zb:sp:alt",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = SecondGatewayDriverId,
ClusterId = SecondClusterId,
NamespaceId = "NS-SP-ALT",
Name = "alt galaxy gateway",
DriverType = "GalaxyMxGateway",
DriverConfig = "{}",
});
db.SaveChanges();
}
private static AliasTagInput Input(
string tagId = "TAG-NEW-ALIAS",
string name = "speed-alias",
string driverInstanceId = GatewayDriverId,
string dataType = "Float",
TagAccessLevel accessLevel = TagAccessLevel.Read,
string fullName = "TestMachine_020.Speed") =>
new(tagId, name, driverInstanceId, dataType, accessLevel, fullName);
/// <summary>
/// A valid create persists a Galaxy-gateway-bound, equipment-scoped alias Tag: <c>FolderPath</c> is
/// null, the supplied AccessLevel is honoured, and the TagConfig is the <c>{"FullName":…}</c> envelope.
/// </summary>
[Fact]
public async Task CreateAliasTag_persists_galaxy_alias_row()
{
var dbName = SeedCluster();
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.CreateAliasTagAsync(
EquipmentId, Input(accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Speed"));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
var tag = db.Tags.Single(t => t.TagId == "TAG-NEW-ALIAS");
tag.DriverInstanceId.ShouldBe(GatewayDriverId);
tag.EquipmentId.ShouldBe(EquipmentId);
tag.FolderPath.ShouldBeNull();
tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
tag.Name.ShouldBe("speed-alias");
tag.DataType.ShouldBe("Float");
using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Speed");
tag.TagConfig.ShouldContain("FullName");
tag.TagConfig.ShouldContain("TestMachine_020.Speed");
tag.WriteIdempotent.ShouldBeFalse();
tag.PollGroupId.ShouldBeNull();
}
/// <summary>An empty/whitespace Galaxy reference is rejected before anything is written.</summary>
[Fact]
public async Task CreateAliasTag_rejects_empty_reference()
{
var dbName = SeedCluster();
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.CreateAliasTagAsync(EquipmentId, Input(fullName: " "));
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("reference");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.TagId == "TAG-NEW-ALIAS").ShouldBeFalse();
}
/// <summary>Binding the alias to a non-Galaxy driver (the cluster's Modbus driver) is rejected.</summary>
[Fact]
public async Task CreateAliasTag_rejects_non_galaxy_driver()
{
var dbName = SeedCluster();
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.CreateAliasTagAsync(
EquipmentId, Input(driverInstanceId: ModbusDriverId));
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("Galaxy gateway");
}
/// <summary>A Galaxy gateway in a different cluster than the equipment is rejected (cluster mismatch).</summary>
[Fact]
public async Task CreateAliasTag_rejects_cross_cluster_gateway()
{
var dbName = SeedCluster();
SeedSecondClusterGateway(dbName);
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.CreateAliasTagAsync(
EquipmentId, Input(driverInstanceId: SecondGatewayDriverId));
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("cluster");
}
/// <summary>A name already used by another tag on the equipment is rejected.</summary>
[Fact]
public async Task CreateAliasTag_rejects_duplicate_name()
{
var dbName = SeedCluster();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.Tags.Add(new Tag
{
TagId = "TAG-EXISTING",
DriverInstanceId = ModbusDriverId,
EquipmentId = EquipmentId,
Name = "speed-alias",
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.SaveChanges();
}
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.CreateAliasTagAsync(EquipmentId, Input(name: "speed-alias"));
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("speed-alias");
}
/// <summary>
/// <see cref="UnsTreeService.LoadAliasTagAsync"/> returns a DTO whose <c>FullName</c> matches the
/// TagConfig and whose <c>RowVersion</c> is non-empty.
/// </summary>
[Fact]
public async Task LoadAliasTag_returns_dto_with_FullName()
{
var dbName = SeedCluster();
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.Tags.Add(new Tag
{
TagId = "TAG-LOAD-ALIAS",
DriverInstanceId = GatewayDriverId,
EquipmentId = EquipmentId,
Name = "loaded-speed",
DataType = "Float",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"FullName\":\"TestMachine_020.LoadedSpeed\"}",
});
db.SaveChanges();
}
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var dto = await service.LoadAliasTagAsync("TAG-LOAD-ALIAS");
dto.ShouldNotBeNull();
dto!.TagId.ShouldBe("TAG-LOAD-ALIAS");
dto.Name.ShouldBe("loaded-speed");
dto.DriverInstanceId.ShouldBe(GatewayDriverId);
dto.DataType.ShouldBe("Float");
dto.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
dto.FullName.ShouldBe("TestMachine_020.LoadedSpeed");
dto.RowVersion.ShouldNotBeNull();
}
/// <summary>
/// Update changes the alias's name, data type, access level, and Galaxy reference, returning Ok and
/// persisting the new values and the refreshed <c>{"FullName":…}</c> TagConfig.
/// </summary>
[Fact]
public async Task UpdateAliasTag_changes_fields_and_reference()
{
var dbName = SeedCluster();
byte[] rowVersion;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.Tags.Add(new Tag
{
TagId = "TAG-UPD-ALIAS",
DriverInstanceId = GatewayDriverId,
EquipmentId = EquipmentId,
Name = "old-name",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}",
});
db.SaveChanges();
rowVersion = db.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS").RowVersion;
}
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.UpdateAliasTagAsync(
"TAG-UPD-ALIAS",
Input(tagId: "TAG-UPD-ALIAS", name: "new-name", dataType: "Double",
accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Setpoint"),
rowVersion);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
var tag = verify.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS");
tag.Name.ShouldBe("new-name");
tag.DataType.ShouldBe("Double");
tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
tag.EquipmentId.ShouldBe(EquipmentId);
tag.FolderPath.ShouldBeNull();
using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Setpoint");
}
}
/// <summary>Small Shouldly-style helper for "exactly one match" assertions used by these tests.</summary>
internal static class SingleItemAssertions
{
public static T ShouldHaveSingleItem<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
var matches = source.Where(predicate).ToList();
matches.Count.ShouldBe(1);
return matches[0];
}
}
@@ -1,614 +0,0 @@
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, bool enabled = true)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = virtualTagId,
EquipmentId = equipmentId,
Name = name,
DataType = dataType,
ScriptId = scriptId,
Historize = historize,
Enabled = enabled,
});
db.SaveChanges();
}
/// <summary>Adds a plain (non-alias) Tag on an equipment, bound to the Modbus driver.</summary>
private static void AddPlainTag(string dbName, string tagId, string equipmentId, string name)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Tags.Add(new Tag
{
TagId = tagId,
DriverInstanceId = ModbusDriverId,
EquipmentId = equipmentId,
Name = name,
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
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();
}
// ----- Fix A: existing Tag name collision -> skip + batch continues -----
/// <summary>
/// When a relay VirtualTag has the same name as an existing Tag on the same equipment the
/// converter must skip it (not throw DbUpdateException from a unique-index violation) and continue
/// converting any other relay on the same equipment in the same batch. The colliding VirtualTag
/// and its Script must remain unchanged; exactly one Tag with that name must exist.
/// </summary>
[Fact]
public async Task Name_collision_with_existing_tag_is_skipped_and_batch_continues()
{
var dbName = SeedCluster();
AddEquipment(dbName, "EQ-1", "m_001");
// Existing plain Tag named "speed-rpm" (e.g. a manually-created Modbus tag).
AddPlainTag(dbName, "TAG-EXISTING", "EQ-1", "speed-rpm");
// Relay VirtualTag also named "speed-rpm" — would collide on the unique index.
AddScript(dbName, "SCRIPT-COLLIDE", Relay("TestMachine_020.SpeedRpm"));
AddVirtualTag(dbName, "VT-COLLIDE", "EQ-1", "speed-rpm", "SCRIPT-COLLIDE");
// A second, non-colliding relay on the same equipment in the same batch.
AddScript(dbName, "SCRIPT-OK", Relay("TestMachine_020.Torque"));
AddVirtualTag(dbName, "VT-OK", "EQ-1", "torque", "SCRIPT-OK");
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false);
// The colliding vtag must appear in Skipped with a reason mentioning "already exists".
var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "speed-rpm");
skip.Reason.ShouldContain("already exists", Case.Insensitive);
// The non-colliding relay must still have been converted (batch was NOT aborted).
result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "torque");
using var db = UnsTreeTestDb.CreateNamed(dbName);
// Exactly one Tag named "speed-rpm" (the original plain tag) — no duplicate was added.
db.Tags.Count(t => t.EquipmentId == "EQ-1" && t.Name == "speed-rpm").ShouldBe(1);
db.Tags.Single(t => t.EquipmentId == "EQ-1" && t.Name == "speed-rpm").TagId.ShouldBe("TAG-EXISTING");
// The colliding VirtualTag (and its script) must still be present.
db.VirtualTags.Any(v => v.VirtualTagId == "VT-COLLIDE").ShouldBeTrue();
db.Scripts.Any(s => s.ScriptId == "SCRIPT-COLLIDE").ShouldBeTrue();
// The non-colliding relay's Tag exists and its vtag + script are gone.
db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "torque").ShouldBeTrue();
db.VirtualTags.Any(v => v.VirtualTagId == "VT-OK").ShouldBeFalse();
db.Scripts.Any(s => s.ScriptId == "SCRIPT-OK").ShouldBeFalse();
}
// ----- Fix B: disabled relay -> skip + report -----
/// <summary>
/// A relay VirtualTag with <c>Enabled == false</c> must be skipped rather than silently promoted
/// into an always-active alias Tag. The reason must mention "disabled". The VirtualTag and Script
/// remain unchanged and no Tag is added.
/// </summary>
[Fact]
public async Task Disabled_relay_is_skipped()
{
var dbName = SeedCluster();
AddEquipment(dbName, "EQ-1", "m_001");
AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.DisabledVal"));
AddVirtualTag(dbName, "VT-1", "EQ-1", "disabled-relay", "SCRIPT-1", enabled: false);
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 == "disabled-relay");
skip.Reason.ShouldContain("disabled", 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 == "disabled-relay").ShouldBeFalse();
}
}