From 5dfb79781728c4bffb793718e9e4a14d9066a5d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 12 Jun 2026 21:28:13 -0400 Subject: [PATCH] refactor(adminui): strip alias/relay machinery from UnsTreeService + EquipmentPage; Galaxy tags use standard TagModal --- .../Components/Pages/Uns/EquipmentPage.razor | 214 +----- .../Uns/EquipmentChildRows.cs | 8 +- .../Uns/IUnsTreeService.cs | 51 -- .../Uns/UnsTreeService.cs | 456 +------------ .../Uns/UnsTreeServiceAliasTagTests.cs | 466 ------------- .../Uns/UnsTreeServiceRelayConverterTests.cs | 614 ------------------ 6 files changed, 6 insertions(+), 1803 deletions(-) delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor index cdbd28dd..5e2d6a10 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/EquipmentPage.razor @@ -139,17 +139,7 @@ else else if (_activeTab == "tags") {
- @if (_gateways.Count == 0) - { - No Galaxy gateway in this cluster - } - - - - - -
@if (!string.IsNullOrWhiteSpace(_tagError)) { @@ -167,7 +157,7 @@ else { - + @foreach (var t in _tags) @@ -177,22 +167,8 @@ else - @@ -201,106 +177,9 @@ else
NameDriverData typeAccessSourceActions
NameDriverData typeAccessActions
@t.DriverInstanceId @t.DataType @t.AccessLevel - @if (t.IsAlias) - { - alias - @t.Source - } - - @if (t.IsAlias) - { - - } - else - { - - } +
} - @if (!string.IsNullOrWhiteSpace(_convertMessage)) - { -
@_convertMessage
- } - - - - @if (!string.IsNullOrWhiteSpace(_convertError)) - { -
@_convertError
- } - - @if (_convertPreview is not null) - { -
-
- Convert relay virtual-tags to aliases - -
-
-
Will convert (@_convertPreview.Converted.Count)
- @if (_convertPreview.Converted.Count == 0) - { -

No relay virtual-tags to convert.

- } - else - { - - - - @foreach (var c in _convertPreview.Converted) - { - - - - - - } - -
Virtual tagFull nameData type
@c.VirtualTagName@c.FullName@c.DataType
- } - -
Skipped (@_convertPreview.Skipped.Count)
- @if (_convertPreview.Skipped.Count == 0) - { -

Nothing skipped.

- } - else - { - - - - @foreach (var s in _convertPreview.Skipped) - { - - - - - } - -
Virtual tagReason
@s.VirtualTagName@s.Reason
- } - -
- @if (_convertPreview.Converted.Count > 0) - { - @if (!_convertConfirming) - { - - } - else - { - Convert @_convertPreview.Converted.Count relay virtual-tag(s)? - - - } - } - @if (!_convertConfirming) - { - - } -
-
-
- } -
-
- - - } 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? _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!); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs index 7e4d6c3d..b1cee5e6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentChildRows.cs @@ -3,13 +3,9 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// 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 GalaxyMxGateway driver) carry IsAlias = true and a -/// Source of "galaxy:<FullName>" taken from the tag's TagConfig; ordinary -/// equipment tags carry IsAlias = false and a null Source. +/// open the edit modal. RowVersion is re-read fresh on delete (matching the tree's delete path). 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); /// A virtual-tag row for the equipment page's Virtual Tags tab table. public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 8fd7004d..4f12c413 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -175,12 +175,6 @@ public interface IUnsTreeService /// The tag's edit projection, or null when missing. Task LoadTagAsync(string tagId, CancellationToken ct = default); - /// Load a Galaxy alias tag for editing (FullName parsed from TagConfig). Null if not found. - /// The alias tag to load. - /// A token to cancel the load. - /// The alias tag's edit projection, or null when missing. - Task LoadAliasTagAsync(string tagId, CancellationToken ct = default); - /// /// Loads a single equipment-bound virtual tag projected for editing, or null 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 DriverType lets the TagModal dispatch to a per-driver-type typed config editor. Task> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default); - /// Galaxy gateway driver instances (DriverType "GalaxyMxGateway") in the equipment's - /// cluster, for the alias address picker. Tuple = (DriverInstanceId, Display, DriverConfig). - /// Display is intentionally "{DriverInstanceId} — {Name}" (no DriverType suffix) - /// because every returned driver is a Galaxy gateway — do not add the type suffix. - Task> - LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default); - /// /// Creates a new equipment-bound tag. FolderPath is always null (decision #110 — /// the tree only edits equipment-bound tags). Fails on a duplicate TagId, invalid @@ -385,33 +372,6 @@ public interface IUnsTreeService /// Success, a missing-row failure, a guard failure, or a concurrency failure. Task UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default); - /// - /// Creates a new Galaxy alias tag on an equipment: an ordinary equipment-bound Tag bound to a - /// GalaxyMxGateway driver, with FolderPath null and a {"FullName":…} TagConfig - /// carrying the picked Galaxy reference. Fails on a duplicate TagId, 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. - /// - /// The owning equipment. - /// The operator-editable alias fields (id, name, gateway, type, access, reference). - /// A token to cancel the operation. - /// Success, or one of the guard failures. - Task CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default); - - /// - /// Updates a Galaxy alias tag's gateway binding, name, data type, access level, and Galaxy reference - /// (the {"FullName":…} TagConfig). The owning EquipmentId and the null FolderPath - /// 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 . - /// - /// The alias tag to update. - /// The new operator-editable alias fields. - /// The concurrency token the caller last read. - /// A token to cancel the operation. - /// Success, a missing-row failure, a guard failure, or a concurrency failure. - Task UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default); - /// /// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins /// optimistic concurrency on . @@ -554,15 +514,4 @@ public interface IUnsTreeService /// A token to cancel the operation. /// Success, a concurrency failure, or a delete-failed failure. Task DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default); - - /// Convert pure-relay VirtualTags (body == return ctx.GetTag("X").Value;) to Galaxy alias - /// Tags. null = fleet-wide; otherwise scoped to that equipment. - /// = true previews without mutating. FleetAdmin-gated at the call site. - /// Operates on the editable config (does not publish). - /// The equipment to scope to; null sweeps every equipment. - /// true previews the conversion without mutating the config. - /// A token to cancel the operation. - /// The per-relay converted and skipped lists, plus whether the pass was applied. - Task ConvertRelayVirtualTagsToAliasesAsync( - string? equipmentId, bool dryRun, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 846a0bc0..e0c0e656 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -86,35 +86,11 @@ public sealed class UnsTreeService(IDbContextFactory dbF public async Task> 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(); } /// @@ -204,16 +180,6 @@ public sealed class UnsTreeService(IDbContextFactory dbF .FirstOrDefaultAsync(ct); } - /// - public async Task 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); - } - /// public async Task LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default) { @@ -774,29 +740,6 @@ public sealed class UnsTreeService(IDbContextFactory dbF .ToList(); } - /// - public async Task> - 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(); - } - /// public async Task CreateTagAsync( string equipmentId, @@ -903,103 +846,6 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } - /// - public async Task 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); - } - - /// - public async Task 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."); - } - } - /// public async Task DeleteTagAsync( string tagId, @@ -1032,195 +878,6 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } - /// - public async Task ConvertRelayVirtualTagsToAliasesAsync( - string? equipmentId, bool dryRun, CancellationToken ct = default) - { - await using var db = await dbFactory.CreateDbContextAsync(ct); - - // Candidate relay virtual tags: the whole draft, or one equipment when scoped. - var candidates = await db.VirtualTags - .Where(v => equipmentId == null || v.EquipmentId == equipmentId) - .OrderBy(v => v.EquipmentId).ThenBy(v => v.Name) - .ToListAsync(ct); - - // Source-by-id for every script a candidate references. A small map keeps the per-vtag relay - // parse a dictionary lookup rather than a query. - var scriptIds = candidates.Select(v => v.ScriptId).Distinct().ToList(); - var scripts = (await db.Scripts.Where(s => scriptIds.Contains(s.ScriptId)).ToListAsync(ct)) - .ToDictionary(s => s.ScriptId, s => s, StringComparer.Ordinal); - - // 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(); - var skipped = new List(); - var aliasTagsToAdd = new List(); - var toRemoveVtags = new List(); - - // Per-equipment caches so a sweep resolves each equipment's cluster, gateway, and (lazily) its - // derivable {{equip}} base only once. - var clusterCache = new Dictionary(StringComparer.Ordinal); - var gatewayCache = new Dictionary(StringComparer.Ordinal); - var equipBaseCache = new Dictionary(StringComparer.Ordinal); - - foreach (var vtag in candidates) - { - var source = scripts.GetValueOrDefault(vtag.ScriptId)?.SourceCode; - if (!EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef) || rawRef is null) - { - skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name, - "Script body is not a pure relay (return ctx.GetTag(\"…\").Value;) — convert manually.")); - continue; - } - - if (!vtag.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); - } - - /// - /// Mints a unique alias TagId following the codebase's id-minting convention — - /// "<prefix>-" + Guid.ToString("N")[..12] (decision #125). A collision with another - /// in-flight id is negligible given the 48-bit GUID prefix; the TagId unique index is the - /// backstop. - /// - private static string NewTagId() => $"TAG-{Guid.NewGuid().ToString("N")[..12]}"; - /// public async Task> LoadScriptsAsync(CancellationToken ct = default) { @@ -1496,32 +1153,6 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } - /// - /// Extracts the FullName string from a tag's TagConfig JSON (the Galaxy reference an - /// alias surfaces), or null when the config is empty, not a JSON object, lacks a string - /// FullName, 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. - /// - 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; - } - } - /// /// Resolves an equipment to its cluster via Equipment.UnsLineId → UnsLine.UnsAreaId → /// UnsArea.ClusterId. Returns null when the equipment, its line, or its area cannot be @@ -1586,89 +1217,6 @@ public sealed class UnsTreeService(IDbContextFactory dbF return null; } - /// - /// Galaxy-aware driver guard for aliases: the driver must be a Galaxy gateway - /// (DriverType == "GalaxyMxGateway") in the equipment's cluster. Distinct from - /// , which requires an Equipment-kind namespace and would reject - /// the gateway. Returns null when the binding is allowed, or a populated failure otherwise. - /// - private static async Task 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; - } - - /// - /// Serialises a Galaxy reference into the alias TagConfig envelope {"FullName":"<ref>"}. - /// - private static string BuildAliasTagConfig(string fullName) => - System.Text.Json.JsonSerializer.Serialize(new Dictionary { ["FullName"] = fullName }); - - /// - /// Builds the alias entity for an equipment: a Galaxy-gateway-bound, equipment-scoped - /// tag with a null FolderPath, no poll group, non-idempotent writes, and the - /// {"FullName":…} TagConfig. A pure builder — reused by the relay→alias converter so create and - /// conversion produce byte-identical rows. - /// - 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), - }; - - /// Lightweight key for the pre-loaded (EquipmentId, Name) tag-name collision set. - private readonly record struct EquipmentName(string EquipmentId, string Name); - - /// - /// Equality comparer for using ordinal string comparison on both fields, - /// matching the database unique index semantics for (EquipmentId, Name). - /// - private sealed class EquipmentNameComparer : IEqualityComparer - { - 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)); - } - /// /// Decision #122: an equipment may only bind to a driver in the same cluster as its line. /// Policy: diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs deleted file mode 100644 index 77924e98..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs +++ /dev/null @@ -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; - -/// -/// Verifies the read-side surface for Galaxy alias tags: that -/// finds the -/// GalaxyMxGateway drivers in the equipment's cluster (and only those), and that -/// flags alias tags (those bound to a Galaxy -/// gateway) with IsAlias = true and a Source derived from the tag's TagConfig. -/// -[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"; - - /// - /// 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. - /// - 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; - } - - /// - /// The gateway lookup returns exactly the GalaxyMxGateway driver (with its id, a display string, - /// and its DriverConfig) and never the cluster's Modbus driver. - /// - [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); - } - - /// An equipment whose cluster has no GalaxyMxGateway driver yields an empty list. - [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(); - } - - /// - /// A tag bound to the Galaxy gateway is an alias (IsAlias = true, Source derived from - /// its TagConfig FullName); a tag bound to the Modbus driver is not (IsAlias = false, - /// Source = null). - /// - [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(); - } - - /// - /// A GalaxyMxGateway-bound tag whose TagConfig carries no FullName (e.g. "{}") is - /// still an alias (IsAlias = true) but has a null Source — the display should not - /// show the literal "galaxy:" string. - /// - [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"; - - /// - /// 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. - /// - 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); - - /// - /// A valid create persists a Galaxy-gateway-bound, equipment-scoped alias Tag: FolderPath is - /// null, the supplied AccessLevel is honoured, and the TagConfig is the {"FullName":…} envelope. - /// - [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(); - } - - /// An empty/whitespace Galaxy reference is rejected before anything is written. - [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(); - } - - /// Binding the alias to a non-Galaxy driver (the cluster's Modbus driver) is rejected. - [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"); - } - - /// A Galaxy gateway in a different cluster than the equipment is rejected (cluster mismatch). - [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"); - } - - /// A name already used by another tag on the equipment is rejected. - [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"); - } - - /// - /// returns a DTO whose FullName matches the - /// TagConfig and whose RowVersion is non-empty. - /// - [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(); - } - - /// - /// Update changes the alias's name, data type, access level, and Galaxy reference, returning Ok and - /// persisting the new values and the refreshed {"FullName":…} TagConfig. - /// - [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"); - } -} - -/// Small Shouldly-style helper for "exactly one match" assertions used by these tests. -internal static class SingleItemAssertions -{ - public static T ShouldHaveSingleItem(this IEnumerable source, Func predicate) - { - var matches = source.Where(predicate).ToList(); - matches.Count.ShouldBe(1); - return matches[0]; - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs deleted file mode 100644 index f46911a7..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceRelayConverterTests.cs +++ /dev/null @@ -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; - -/// -/// Verifies : the converter that -/// rewrites pure-relay VirtualTags (body == return ctx.GetTag("X").Value;) into equivalent -/// Galaxy alias rows, deletes the relay VirtualTag, and prunes the orphaned -/// when nothing else references it. Covers conversion, the skip reasons -/// (non-relay, historized, no gateway, unresolvable {{equip}}), dry-run, scope, shared-script -/// retention/pruning, and the {{equip}} expansion path. -/// -[Trait("Category", "Unit")] -public sealed class UnsTreeServiceRelayConverterTests -{ - private const string ClusterId = "MAIN"; - private const string GatewayDriverId = "DRV-GALAXY"; - private const string ModbusDriverId = "DRV-MODBUS"; - - /// - /// Seeds a cluster with a SystemPlatform namespace + GalaxyMxGateway driver (optional), an - /// Equipment-kind namespace + Modbus driver, an area→line, and returns the InMemory db name. - /// Equipment rows are added by the caller via . - /// - private static string SeedCluster(bool withGalaxyGateway = true) - { - var dbName = $"uns-relay-{Guid.NewGuid():N}"; - using var db = UnsTreeTestDb.CreateNamed(dbName); - - db.ServerClusters.Add(new ServerCluster - { - ClusterId = ClusterId, - Name = "Main", - Enterprise = "zb", - Site = "warsaw-west", - RedundancyMode = RedundancyMode.None, - CreatedBy = "test", - }); - db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = ClusterId, Name = "a" }); - db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); - - db.Namespaces.Add(new Namespace - { - NamespaceId = "NS-SP", - ClusterId = ClusterId, - Kind = NamespaceKind.SystemPlatform, - NamespaceUri = "urn:zb:sp", - }); - if (withGalaxyGateway) - { - db.DriverInstances.Add(new DriverInstance - { - DriverInstanceId = GatewayDriverId, - ClusterId = ClusterId, - NamespaceId = "NS-SP", - Name = "galaxy gateway", - DriverType = "GalaxyMxGateway", - DriverConfig = "{}", - }); - } - - db.Namespaces.Add(new Namespace - { - NamespaceId = "NS-EQ", - ClusterId = ClusterId, - Kind = NamespaceKind.Equipment, - NamespaceUri = "urn:zb:eq", - }); - db.DriverInstances.Add(new DriverInstance - { - DriverInstanceId = ModbusDriverId, - ClusterId = ClusterId, - NamespaceId = "NS-EQ", - Name = "modbus driver", - DriverType = "Modbus", - DriverConfig = "{}", - }); - - db.SaveChanges(); - return dbName; - } - - /// Adds an equipment under LINE-1 in the seeded cluster. - private static void AddEquipment(string dbName, string equipmentId, string machineCode) - { - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.Equipment.Add(new Equipment - { - EquipmentId = equipmentId, - EquipmentUuid = Guid.NewGuid(), - UnsLineId = "LINE-1", - Name = equipmentId, - MachineCode = machineCode, - }); - db.SaveChanges(); - } - - /// Adds a script row with the given source. - private static void AddScript(string dbName, string scriptId, string source) - { - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.Scripts.Add(new Script - { - ScriptId = scriptId, - Name = scriptId, - SourceCode = source, - SourceHash = $"hash-{scriptId}", - }); - db.SaveChanges(); - } - - /// Adds a virtual tag bound to a script on an equipment. - private static void AddVirtualTag( - string dbName, string virtualTagId, string equipmentId, string name, string scriptId, - string dataType = "Int32", bool historize = false, 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(); - } - - /// Adds a plain (non-alias) Tag on an equipment, bound to the Modbus driver. - 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(); - } - - /// Adds an existing Galaxy alias Tag on an equipment (used for {{equip}} base derivation). - private static void AddAliasTag(string dbName, string tagId, string equipmentId, string name, string fullName) - { - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.Tags.Add(new Tag - { - TagId = tagId, - DriverInstanceId = GatewayDriverId, - EquipmentId = equipmentId, - Name = name, - DataType = "Float", - AccessLevel = TagAccessLevel.Read, - TagConfig = $"{{\"FullName\":\"{fullName}\"}}", - }); - db.SaveChanges(); - } - - private static string Relay(string reference) => $"return ctx.GetTag(\"{reference}\").Value;"; - - // ----- Case 1: exact relay converted (apply) ----- - - /// - /// A pure-relay virtual tag is replaced by a Galaxy alias Tag (gateway-bound, FolderPath null, - /// AccessLevel Read, FullName + DataType carried over). The relay VirtualTag and its now-orphan - /// Script are gone. The result reports the item and Applied == true. - /// - [Fact] - public async Task Exact_relay_is_converted_to_alias_tag() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.TestChangingInt")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "speed-rpm", "SCRIPT-1", dataType: "Int32"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Applied.ShouldBeTrue(); - var item = result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed-rpm"); - item.EquipmentId.ShouldBe("EQ-1"); - item.FullName.ShouldBe("TestMachine_020.TestChangingInt"); - item.DataType.ShouldBe("Int32"); - result.Skipped.ShouldBeEmpty(); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - var tag = db.Tags.Single(t => t.Name == "speed-rpm"); - tag.DriverInstanceId.ShouldBe(GatewayDriverId); - tag.EquipmentId.ShouldBe("EQ-1"); - tag.FolderPath.ShouldBeNull(); - tag.AccessLevel.ShouldBe(TagAccessLevel.Read); - tag.DataType.ShouldBe("Int32"); - tag.WriteIdempotent.ShouldBeFalse(); - tag.PollGroupId.ShouldBeNull(); - using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig); - doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.TestChangingInt"); - - db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeFalse(); - db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeFalse(); - } - - // ----- Case 2: non-relay untouched ----- - - /// A computed body (not a pure relay) is skipped; the VirtualTag and Script remain. - [Fact] - public async Task Non_relay_is_skipped_and_unchanged() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddScript(dbName, "SCRIPT-1", "return ctx.GetTag(\"A.B\").Value * 2;"); - AddVirtualTag(dbName, "VT-1", "EQ-1", "doubled", "SCRIPT-1"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Converted.ShouldBeEmpty(); - var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "doubled"); - skip.Reason.ShouldContain("relay"); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); - db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue(); - db.Tags.Any(t => t.Name == "doubled").ShouldBeFalse(); - } - - // ----- Case 3a: two relays share a script -> deleted after both convert ----- - - /// - /// One script backs two relay virtual tags. A fleet-wide apply converts both, so the now-orphan - /// script is deleted. - /// - [Fact] - public async Task Shared_script_deleted_when_all_referencing_relays_converted() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddEquipment(dbName, "EQ-2", "m_002"); - AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared")); - AddVirtualTag(dbName, "VT-A", "EQ-1", "a", "SCRIPT-SHARED"); - AddVirtualTag(dbName, "VT-B", "EQ-2", "b", "SCRIPT-SHARED"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync(null, dryRun: false); - - result.Converted.Count.ShouldBe(2); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.VirtualTags.Any().ShouldBeFalse(); - db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeFalse(); - db.Tags.Count(t => t.Name == "a" || t.Name == "b").ShouldBe(2); - } - - // ----- Case 3b: scoped convert keeps the script while another relay still needs it ----- - - /// - /// One script backs two relay virtual tags on different equipments. A scoped apply (EQ-1 only) - /// converts only the first; the script remains because the second relay still references it. A - /// follow-up apply for EQ-2 then deletes the script. - /// - [Fact] - public async Task Shared_script_kept_then_deleted_across_scoped_passes() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddEquipment(dbName, "EQ-2", "m_002"); - AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared")); - AddVirtualTag(dbName, "VT-A", "EQ-1", "a", "SCRIPT-SHARED"); - AddVirtualTag(dbName, "VT-B", "EQ-2", "b", "SCRIPT-SHARED"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - - var first = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - first.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "a"); - - using (var db = UnsTreeTestDb.CreateNamed(dbName)) - { - db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeTrue(); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-B").ShouldBeTrue(); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-A").ShouldBeFalse(); - } - - var second = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-2", dryRun: false); - second.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "b"); - - using (var db = UnsTreeTestDb.CreateNamed(dbName)) - { - db.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeFalse(); - } - } - - // ----- Case 3c: relay shares its script with a NON-relay vtag (scoped) -> script kept ----- - - /// - /// A relay shares its (pure-relay) script with another virtual tag on a different equipment. A - /// scoped apply converts only the relay's equipment; the script is kept because the other virtual - /// tag — left outside scope, so it is not a conversion candidate this pass — still references it. - /// This is the "still used by a not-removed vtag" branch of the orphan check. - /// - [Fact] - public async Task Script_kept_when_other_referencing_vtag_is_out_of_scope() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddEquipment(dbName, "EQ-2", "m_002"); - AddScript(dbName, "SCRIPT-SHARED", Relay("TestMachine_020.Shared")); - AddVirtualTag(dbName, "VT-RELAY", "EQ-1", "relayed", "SCRIPT-SHARED"); - AddVirtualTag(dbName, "VT-OTHER", "EQ-2", "other", "SCRIPT-SHARED"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "relayed"); - - using var verify = UnsTreeTestDb.CreateNamed(dbName); - verify.Scripts.Any(s => s.ScriptId == "SCRIPT-SHARED").ShouldBeTrue(); - verify.VirtualTags.Any(v => v.VirtualTagId == "VT-OTHER").ShouldBeTrue(); - verify.VirtualTags.Any(v => v.VirtualTagId == "VT-RELAY").ShouldBeFalse(); - } - - // ----- Case 3d: relay shares its script with a ScriptedAlarm predicate -> script kept ----- - - /// - /// A relay's script is also a ScriptedAlarm predicate. Converting the relay must leave the script - /// in place because the alarm still references it. - /// - [Fact] - public async Task Script_kept_when_referenced_by_scripted_alarm() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddScript(dbName, "SCRIPT-PRED", Relay("TestMachine_020.Pred")); - AddVirtualTag(dbName, "VT-RELAY", "EQ-1", "relayed", "SCRIPT-PRED"); - - using (var db = UnsTreeTestDb.CreateNamed(dbName)) - { - db.ScriptedAlarms.Add(new ScriptedAlarm - { - ScriptedAlarmId = "AL-1", - EquipmentId = "EQ-1", - Name = "alarm", - AlarmType = "AlarmCondition", - MessageTemplate = "x", - PredicateScriptId = "SCRIPT-PRED", - }); - db.SaveChanges(); - } - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "relayed"); - - using var verify = UnsTreeTestDb.CreateNamed(dbName); - verify.Scripts.Any(s => s.ScriptId == "SCRIPT-PRED").ShouldBeTrue(); - verify.VirtualTags.Any(v => v.VirtualTagId == "VT-RELAY").ShouldBeFalse(); - } - - // ----- Case 4: Historize=true skipped ----- - - /// A historized relay virtual tag is skipped (Tag has no historize column) and unchanged. - [Fact] - public async Task Historized_relay_is_skipped() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.Hist")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "histtag", "SCRIPT-1", historize: true); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Converted.ShouldBeEmpty(); - var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "histtag"); - skip.Reason.ShouldContain("historiz", Case.Insensitive); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); - db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue(); - db.Tags.Any(t => t.Name == "histtag").ShouldBeFalse(); - } - - // ----- Case 5: no gateway ----- - - /// A relay on an equipment whose cluster has no Galaxy gateway is skipped, unchanged. - [Fact] - public async Task Relay_skipped_when_no_galaxy_gateway() - { - var dbName = SeedCluster(withGalaxyGateway: false); - AddEquipment(dbName, "EQ-1", "m_001"); - AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.X")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "nogw", "SCRIPT-1"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Converted.ShouldBeEmpty(); - var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "nogw"); - skip.Reason.ShouldContain("no Galaxy gateway"); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); - db.Tags.Any(t => t.Name == "nogw").ShouldBeFalse(); - } - - // ----- Case 6: dry-run no mutation ----- - - /// A dry-run lists the convertible relay but mutates nothing; Applied == false. - [Fact] - public async Task Dry_run_lists_but_does_not_mutate() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.TestChangingInt")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "speed-rpm", "SCRIPT-1"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: true); - - result.Applied.ShouldBeFalse(); - result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed-rpm"); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); - db.Scripts.Any(s => s.ScriptId == "SCRIPT-1").ShouldBeTrue(); - db.Tags.Any(t => t.Name == "speed-rpm").ShouldBeFalse(); - } - - // ----- Case 7: scope ----- - - /// A fleet-wide (null) scope sweeps every equipment's relays. - [Fact] - public async Task Fleet_wide_scope_converts_all_equipments() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddEquipment(dbName, "EQ-2", "m_002"); - AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.A")); - AddScript(dbName, "SCRIPT-2", Relay("TestMachine_020.B")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "a", "SCRIPT-1"); - AddVirtualTag(dbName, "VT-2", "EQ-2", "b", "SCRIPT-2"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync(null, dryRun: false); - - result.Converted.Count.ShouldBe(2); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "a").ShouldBeTrue(); - db.Tags.Any(t => t.EquipmentId == "EQ-2" && t.Name == "b").ShouldBeTrue(); - } - - /// A scoped (single-equipment) call converts only that equipment's relay. - [Fact] - public async Task Scoped_call_converts_only_target_equipment() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddEquipment(dbName, "EQ-2", "m_002"); - AddScript(dbName, "SCRIPT-1", Relay("TestMachine_020.A")); - AddScript(dbName, "SCRIPT-2", Relay("TestMachine_020.B")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "a", "SCRIPT-1"); - AddVirtualTag(dbName, "VT-2", "EQ-2", "b", "SCRIPT-2"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Converted.ShouldHaveSingleItem(i => i.EquipmentId == "EQ-1"); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.Tags.Any(t => t.EquipmentId == "EQ-1" && t.Name == "a").ShouldBeTrue(); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-2").ShouldBeTrue(); - db.Tags.Any(t => t.EquipmentId == "EQ-2").ShouldBeFalse(); - } - - // ----- Case 8: {{equip}} expansion ----- - - /// - /// A relay using the {{equip}} token on an equipment with an existing alias Tag (so a base - /// is derivable) is converted with the token expanded against that base. - /// - [Fact] - public async Task Equip_token_relay_expands_against_derived_base() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddAliasTag(dbName, "TAG-EXISTING", "EQ-1", "other", "TestMachine_020.Other"); - AddScript(dbName, "SCRIPT-1", Relay("{{equip}}.Speed")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "speed", "SCRIPT-1"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - var item = result.Converted.ShouldHaveSingleItem(i => i.VirtualTagName == "speed"); - item.FullName.ShouldBe("TestMachine_020.Speed"); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - var tag = db.Tags.Single(t => t.Name == "speed"); - using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig); - doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Speed"); - } - - /// - /// A relay using the {{equip}} token on an equipment with no derivable base (no existing - /// alias tags) is skipped with a reason mentioning the equipment-relative base. - /// - [Fact] - public async Task Equip_token_relay_skipped_when_no_derivable_base() - { - var dbName = SeedCluster(); - AddEquipment(dbName, "EQ-1", "m_001"); - AddScript(dbName, "SCRIPT-1", Relay("{{equip}}.Speed")); - AddVirtualTag(dbName, "VT-1", "EQ-1", "speed", "SCRIPT-1"); - - var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName)); - var result = await service.ConvertRelayVirtualTagsToAliasesAsync("EQ-1", dryRun: false); - - result.Converted.ShouldBeEmpty(); - var skip = result.Skipped.ShouldHaveSingleItem(s => s.VirtualTagName == "speed"); - skip.Reason.ShouldContain("base", Case.Insensitive); - - using var db = UnsTreeTestDb.CreateNamed(dbName); - db.VirtualTags.Any(v => v.VirtualTagId == "VT-1").ShouldBeTrue(); - db.Tags.Any(t => t.Name == "speed").ShouldBeFalse(); - } - - // ----- Fix A: existing Tag name collision -> skip + batch continues ----- - - /// - /// 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. - /// - [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 ----- - - /// - /// A relay VirtualTag with Enabled == false 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. - /// - [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(); - } -}