diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor index 6ae88e08..841bc04b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor @@ -41,6 +41,7 @@ @@ -72,6 +73,22 @@ OnSaved="OnModalSavedAsync" OnCancel="CloseModals" /> + + + + @if (_confirmNode is not null) { @@ -127,6 +144,23 @@ private IReadOnlyList<(string Id, string Display)> _equipmentModalLineOptions = Array.Empty<(string, string)>(); private IReadOnlyList<(string Id, string Display)> _equipmentModalDriverOptions = Array.Empty<(string, string)>(); + // --- Tag modal state --- + private bool _tagModalVisible; + private bool _tagModalIsNew; + private string? _tagModalEquipmentId; + private TagEditDto? _tagModalExisting; + private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>(); + + // --- Virtual-tag modal state --- + private bool _vtagModalVisible; + private bool _vtagModalIsNew; + private string? _vtagModalEquipmentId; + private VirtualTagEditDto? _vtagModalExisting; + private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>(); + + // --- Owning equipment to refresh in place after a tag/virtual-tag mutation --- + private string? _childRefreshEquipmentId; + // --- Delete-confirm state --- private UnsNode? _confirmNode; private bool _confirmBusy; @@ -204,8 +238,8 @@ /// /// Opens the create modal for a node's primary child: a cluster gets a new area; an area gets a - /// new line scoped to its cluster; a line gets a new equipment scoped to its cluster. Equipment - /// "+ Tag" is handled in a later task. + /// new line scoped to its cluster; a line gets a new equipment scoped to its cluster; an equipment + /// gets a new tag scoped to its candidate drivers. /// private async Task HandleAddChild(UnsNode node) { @@ -235,12 +269,34 @@ _equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!); _equipmentModalVisible = true; break; + + case UnsNodeKind.Equipment: + _tagModalIsNew = true; + _tagModalExisting = null; + _tagModalEquipmentId = node.EntityId; + _childRefreshEquipmentId = node.EntityId; + _tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(node.EntityId!); + _tagModalVisible = true; + break; } } + /// Opens the create modal for a new virtual tag scoped to the clicked equipment. + private async Task HandleAddVirtualTag(UnsNode node) + { + CloseModals(); + _vtagModalIsNew = true; + _vtagModalExisting = null; + _vtagModalEquipmentId = node.EntityId; + _childRefreshEquipmentId = node.EntityId; + _vtagModalScriptOptions = await Svc.LoadScriptsAsync(); + _vtagModalVisible = true; + } + /// - /// Opens the edit modal for an Area or Line, loading the entity first to prefill the form and - /// capture its RowVersion. Other kinds are handled in later tasks. + /// Opens the edit modal for the clicked node, loading the entity first to prefill the form and + /// capture its RowVersion. Tag/VirtualTag edits also stash the owning equipment id so a successful + /// save can refresh just that equipment's children in place. /// private async Task HandleEdit(UnsNode node) { @@ -276,6 +332,28 @@ _equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!); _equipmentModalVisible = true; break; + + case UnsNodeKind.Tag: + var tag = await Svc.LoadTagAsync(node.EntityId!); + if (tag is null) { return; } + _tagModalIsNew = false; + _tagModalExisting = tag; + _tagModalEquipmentId = tag.EquipmentId; + _childRefreshEquipmentId = tag.EquipmentId; + _tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(tag.EquipmentId); + _tagModalVisible = true; + break; + + case UnsNodeKind.VirtualTag: + var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!); + if (vtag is null) { return; } + _vtagModalIsNew = false; + _vtagModalExisting = vtag; + _vtagModalEquipmentId = vtag.EquipmentId; + _childRefreshEquipmentId = vtag.EquipmentId; + _vtagModalScriptOptions = await Svc.LoadScriptsAsync(); + _vtagModalVisible = true; + break; } } @@ -288,7 +366,8 @@ /// /// Performs the pending delete. Loads the entity's RowVersion first, then dispatches on Kind. - /// Area/Line/Equipment are handled here; Tag/VirtualTag are wired in later tasks. + /// Area/Line/Equipment trigger a full structural reload on success; Tag/VirtualTag refresh only the + /// owning equipment's children in place so the rest of the user's expansion is preserved. /// private async Task ConfirmDeleteAsync() { @@ -300,8 +379,43 @@ { var node = _confirmNode; UnsMutationResult result; + + // Tag/VirtualTag deletes refresh just the owning equipment's children rather than the + // whole tree, so they're handled separately from the structural Area/Line/Equipment path. switch (node.Kind) { + case UnsNodeKind.Tag: + var tag = await Svc.LoadTagAsync(node.EntityId!); + if (tag is null) { await ReloadAndCloseAsync(); return; } + result = await Svc.DeleteTagAsync(node.EntityId!, tag.RowVersion); + if (result.Ok) + { + await RefreshEquipmentChildrenAsync(tag.EquipmentId); + CloseModals(); + StateHasChanged(); + } + else + { + _confirmError = result.Error; + } + return; + + case UnsNodeKind.VirtualTag: + var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!); + if (vtag is null) { await ReloadAndCloseAsync(); return; } + result = await Svc.DeleteVirtualTagAsync(node.EntityId!, vtag.RowVersion); + if (result.Ok) + { + await RefreshEquipmentChildrenAsync(vtag.EquipmentId); + CloseModals(); + StateHasChanged(); + } + else + { + _confirmError = result.Error; + } + return; + case UnsNodeKind.Area: var area = await Svc.LoadAreaAsync(node.EntityId!); if (area is null) { await ReloadAndCloseAsync(); return; } @@ -321,7 +435,7 @@ break; default: - // Tag/VirtualTag deletes are wired in later tasks. + // Enterprise/Cluster have no delete button, so this branch is unreachable in practice. result = new UnsMutationResult(false, "Delete for this node kind is not yet available."); break; } @@ -349,6 +463,64 @@ StateHasChanged(); } + /// + /// Handles a successful Tag/VirtualTag modal save by refreshing only the owning equipment's children + /// in place — never a full structural reload, which would collapse the user's expansion. + /// + private async Task OnEquipmentChildModalSavedAsync() + { + if (_childRefreshEquipmentId is not null) + { + await RefreshEquipmentChildrenAsync(_childRefreshEquipmentId); + } + CloseModals(); + StateHasChanged(); + } + + /// + /// Reloads a single equipment node's tag/virtual-tag children in place, leaving the rest of the tree + /// (and the user's expansion) untouched. Falls back to a full structural reload only if the node + /// can no longer be found in the current tree. + /// + private async Task RefreshEquipmentChildrenAsync(string equipmentId) + { + var node = FindEquipmentNode(equipmentId); + if (node is null) { _roots = await Svc.LoadStructureAsync(); return; } + + var kids = await Svc.LoadEquipmentChildrenAsync(equipmentId); + node.Children.Clear(); + node.Children.AddRange(kids); + node.ChildCount = node.Children.Count; + node.Loaded = true; + node.Expanded = true; + } + + /// Recursively walks the current tree for the Equipment node with the given id, or null. + private UnsNode? FindEquipmentNode(string equipmentId) + { + foreach (var root in _roots) + { + var found = FindEquipmentNode(root, equipmentId); + if (found is not null) { return found; } + } + return null; + } + + private static UnsNode? FindEquipmentNode(UnsNode node, string equipmentId) + { + if (node.Kind == UnsNodeKind.Equipment && node.EntityId == equipmentId) + { + return node; + } + + foreach (var child in node.Children) + { + var found = FindEquipmentNode(child, equipmentId); + if (found is not null) { return found; } + } + return null; + } + /// Reloads the tree after a successful delete and closes the confirm modal. private async Task ReloadAndCloseAsync() { @@ -369,6 +541,15 @@ _equipmentModalExisting = null; _equipmentModalLineOptions = Array.Empty<(string, string)>(); _equipmentModalDriverOptions = Array.Empty<(string, string)>(); + _tagModalVisible = false; + _tagModalExisting = null; + _tagModalEquipmentId = null; + _tagModalDriverOptions = Array.Empty<(string, string)>(); + _vtagModalVisible = false; + _vtagModalExisting = null; + _vtagModalEquipmentId = null; + _vtagModalScriptOptions = Array.Empty<(string, string)>(); + _childRefreshEquipmentId = null; _confirmNode = null; _confirmError = null; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor new file mode 100644 index 00000000..c9c9d701 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor @@ -0,0 +1,212 @@ +@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page + owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus + the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so + the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector + either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can + refresh the equipment's children in place. *@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IUnsTreeService Svc + +@if (Visible) +{ + + +} + +@code { + private static readonly string[] DataTypes = + ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", + "Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"]; + + /// Whether the modal is shown. The host owns this flag. + [Parameter] public bool Visible { get; set; } + + /// true to create a new tag; false to edit . + [Parameter] public bool IsNew { get; set; } + + /// The owning equipment id the created tag binds to (used only on create). + [Parameter] public string? EquipmentId { get; set; } + + /// The tag being edited, when is false. + [Parameter] public TagEditDto? Existing { get; set; } + + /// The candidate drivers — scoped to the equipment's cluster by the host — as (Id, Display) pairs. + [Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>(); + + /// Raised after a successful create/save so the host can refresh the equipment's children and close. + [Parameter] public EventCallback OnSaved { get; set; } + + /// Raised when the user cancels so the host can close. + [Parameter] public EventCallback OnCancel { get; set; } + + private FormModel _form = new(); + private bool _busy; + private string? _error; + + protected override void OnParametersSet() + { + // Rebuild the working form whenever the host (re)opens the modal for a fresh target. + if (IsNew) + { + _form = new FormModel(); + } + else if (Existing is not null) + { + _form = new FormModel + { + TagId = Existing.TagId, + Name = Existing.Name, + DriverInstanceId = Existing.DriverInstanceId, + DataType = Existing.DataType, + AccessLevel = Existing.AccessLevel, + WriteIdempotent = Existing.WriteIdempotent, + PollGroupId = Existing.PollGroupId, + TagConfig = Existing.TagConfig, + }; + } + _error = null; + } + + private async Task SaveAsync() + { + _busy = true; + _error = null; + try + { + var input = new TagInput( + _form.TagId, + _form.Name, + _form.DriverInstanceId, + _form.DataType, + _form.AccessLevel, + _form.WriteIdempotent, + _form.PollGroupId, + _form.TagConfig); + + var result = IsNew + ? await Svc.CreateTagAsync(EquipmentId!, input) + : await Svc.UpdateTagAsync(Existing!.TagId, input, Existing.RowVersion); + + if (result.Ok) + { + await OnSaved.InvokeAsync(); + } + else + { + _error = result.Error; + } + } + finally + { + _busy = false; + } + } + + private Task CancelAsync() => OnCancel.InvokeAsync(); + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + [Required] public string DriverInstanceId { get; set; } = ""; + public string DataType { get; set; } = "Float"; + public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read; + public bool WriteIdempotent { get; set; } + public string? PollGroupId { get; set; } + [Required] public string TagConfig { get; set; } = "{}"; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor new file mode 100644 index 00000000..4b8408b7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor @@ -0,0 +1,201 @@ +@* Create/edit modal for an equipment-bound virtual tag, wired straight into IUnsTreeService. The host + page owns visibility and supplies the owning equipment id (create) or the loaded VirtualTagEditDto + (edit), plus the script list. Virtual tags are always scoped to an equipment (plan decision #2) and + that binding never moves, so this modal deliberately offers NO equipment selector — the owning + equipment is fixed. On a successful save it raises OnSaved so the host can refresh the equipment's + children in place. *@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@inject IUnsTreeService Svc + +@if (Visible) +{ + + +} + +@code { + /// Whether the modal is shown. The host owns this flag. + [Parameter] public bool Visible { get; set; } + + /// true to create a new virtual tag; false to edit . + [Parameter] public bool IsNew { get; set; } + + /// The owning equipment id the created virtual tag binds to (used only on create). + [Parameter] public string? EquipmentId { get; set; } + + /// The virtual tag being edited, when is false. + [Parameter] public VirtualTagEditDto? Existing { get; set; } + + /// The selectable scripts as (Id, Display) pairs. + [Parameter] public IReadOnlyList<(string Id, string Display)> Scripts { get; set; } = Array.Empty<(string, string)>(); + + /// Raised after a successful create/save so the host can refresh the equipment's children and close. + [Parameter] public EventCallback OnSaved { get; set; } + + /// Raised when the user cancels so the host can close. + [Parameter] public EventCallback OnCancel { get; set; } + + private FormModel _form = new(); + private bool _busy; + private string? _error; + + protected override void OnParametersSet() + { + // Rebuild the working form whenever the host (re)opens the modal for a fresh target. + if (IsNew) + { + _form = new FormModel(); + } + else if (Existing is not null) + { + _form = new FormModel + { + VirtualTagId = Existing.VirtualTagId, + Name = Existing.Name, + DataType = Existing.DataType, + ScriptId = Existing.ScriptId, + ChangeTriggered = Existing.ChangeTriggered, + TimerIntervalMs = Existing.TimerIntervalMs, + Historize = Existing.Historize, + Enabled = Existing.Enabled, + }; + } + _error = null; + } + + private async Task SaveAsync() + { + _busy = true; + _error = null; + try + { + var input = new VirtualTagInput( + _form.VirtualTagId, + _form.Name, + _form.DataType, + _form.ScriptId, + _form.ChangeTriggered, + _form.TimerIntervalMs, + _form.Historize, + _form.Enabled); + + var result = IsNew + ? await Svc.CreateVirtualTagAsync(EquipmentId!, input) + : await Svc.UpdateVirtualTagAsync(Existing!.VirtualTagId, input, Existing.RowVersion); + + if (result.Ok) + { + await OnSaved.InvokeAsync(); + } + else + { + _error = result.Error; + } + } + finally + { + _busy = false; + } + } + + private Task CancelAsync() => OnCancel.InvokeAsync(); + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string VirtualTagId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + public string DataType { get; set; } = "Double"; + [Required] public string ScriptId { get; set; } = ""; + public bool ChangeTriggered { get; set; } = true; + public int? TimerIntervalMs { get; set; } + public bool Historize { get; set; } + public bool Enabled { get; set; } = true; + } +} 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 7aca7ae6..548643d3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// @@ -49,6 +51,44 @@ public sealed record EquipmentEditDto(string EquipmentId, string Name, string Ma string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation, string? ManufacturerUri, string? DeviceManualUri, bool Enabled, byte[] RowVersion); +/// +/// An equipment-bound tag projected for editing: its operator-editable fields, the owning equipment +/// (so the host can scope the candidate-driver list and refresh the right node), plus the concurrency +/// token the edit modal must echo back on save. Tree tags are always equipment-bound (decision #110), +/// so FolderPath never surfaces here. +/// +/// The tag's stable id (read-only on edit). +/// The owning equipment id. +/// The tag name. +/// The bound driver id. +/// The OPC UA built-in type name. +/// The tag-level access baseline. +/// Whether writes are safe to retry. +/// Optional poll-group key; null when unset. +/// The schemaless per-driver-type JSON config. +/// The optimistic-concurrency token last read. +public sealed record TagEditDto(string TagId, string EquipmentId, string Name, string DriverInstanceId, string DataType, + TagAccessLevel AccessLevel, bool WriteIdempotent, string? PollGroupId, string TagConfig, byte[] RowVersion); + +/// +/// An equipment-bound virtual tag projected for editing: its operator-editable fields, the owning +/// equipment (so the host can refresh the right node), plus the concurrency token the edit modal must +/// echo back on save. Virtual tags are always scoped to an equipment (plan decision #2), so the modal +/// never offers an equipment-change control. +/// +/// The virtual tag's stable id (read-only on edit). +/// The owning equipment id. +/// The virtual-tag name. +/// The OPC UA built-in type name. +/// The bound script id. +/// Whether the tag re-evaluates on dependency change. +/// Optional periodic re-evaluation cadence in ms; null when unset. +/// Whether the tag's values are historized. +/// Whether the tag is spawned in deployments. +/// The optimistic-concurrency token last read. +public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId, string Name, string DataType, string ScriptId, + bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled, byte[] RowVersion); + /// /// Loads the structural portion of the unified-namespace (UNS) browse tree — /// Enterprise → Cluster → Area → Line → Equipment — from the config database. @@ -103,6 +143,24 @@ public interface IUnsTreeService /// The equipment's edit projection, or null when missing. Task LoadEquipmentAsync(string equipmentId, CancellationToken ct = default); + /// + /// Loads a single equipment-bound tag projected for editing, or null if it no longer exists. + /// Reads untracked and captures the current concurrency token for last-write-wins saves. + /// + /// The tag to load. + /// A token to cancel the load. + /// The tag's edit projection, or null when missing. + Task LoadTagAsync(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. + /// + /// The virtual tag to load. + /// A token to cancel the load. + /// The virtual tag's edit projection, or null when missing. + Task LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default); + /// /// Loads every driver instance in a cluster (regardless of namespace kind) so the equipment modal /// can offer the full cluster driver list for binding. Ordered by DriverInstanceId. Each is 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 1a819f1d..93f7c179 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -178,6 +178,50 @@ public sealed class UnsTreeService(IDbContextFactory dbF .FirstOrDefaultAsync(ct); } + /// + public async Task LoadTagAsync(string tagId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return await db.Tags + .AsNoTracking() + .Where(t => t.TagId == tagId) + .Select(t => new TagEditDto( + t.TagId, + t.EquipmentId!, + t.Name, + t.DriverInstanceId, + t.DataType, + t.AccessLevel, + t.WriteIdempotent, + t.PollGroupId, + t.TagConfig, + t.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + /// + public async Task LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return await db.VirtualTags + .AsNoTracking() + .Where(v => v.VirtualTagId == virtualTagId) + .Select(v => new VirtualTagEditDto( + v.VirtualTagId, + v.EquipmentId, + v.Name, + v.DataType, + v.ScriptId, + v.ChangeTriggered, + v.TimerIntervalMs, + v.Historize, + v.Enabled, + v.RowVersion)) + .FirstOrDefaultAsync(ct); + } + /// public async Task> LoadDriversForClusterAsync( string clusterId, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs index 1a509635..798e2e5b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceLoadEditTests.cs @@ -2,6 +2,7 @@ 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; @@ -142,4 +143,58 @@ public sealed class UnsTreeServiceLoadEditTests drivers[1].DriverInstanceId.ShouldBe("DRV-B"); drivers[1].Display.ShouldBe("DRV-B — modbus-b (ModbusTcp)"); } + + /// Loading a seeded tag maps its fields, owning equipment, and a non-empty RowVersion. + [Fact] + public async Task LoadTag_returns_dto() + { + var (service, _) = Seeded(); + + var dto = await service.LoadTagAsync("TAG-1"); + + dto.ShouldNotBeNull(); + dto.TagId.ShouldBe("TAG-1"); + dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId); + dto.Name.ShouldBe("speed"); + dto.DriverInstanceId.ShouldBe("DRV-1"); + dto.DataType.ShouldBe("Float"); + dto.AccessLevel.ShouldBe(TagAccessLevel.Read); + dto.TagConfig.ShouldBe("{}"); + dto.RowVersion.ShouldNotBeNull(); + } + + /// Loading a missing tag returns null. + [Fact] + public async Task LoadTag_missing_returns_null() + { + var (service, _) = Seeded(); + + (await service.LoadTagAsync("NOPE")).ShouldBeNull(); + } + + /// Loading a seeded virtual tag maps its fields, owning equipment, and a non-empty RowVersion. + [Fact] + public async Task LoadVirtualTag_returns_dto() + { + var (service, _) = Seeded(); + + var dto = await service.LoadVirtualTagAsync("VTAG-1"); + + dto.ShouldNotBeNull(); + dto.VirtualTagId.ShouldBe("VTAG-1"); + dto.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId); + dto.Name.ShouldBe("computed"); + dto.DataType.ShouldBe("Double"); + dto.ScriptId.ShouldBe("SCRIPT-1"); + dto.RowVersion.ShouldNotBeNull(); + } + + /// Loading a missing virtual tag returns null. + [Fact] + public async Task LoadVirtualTag_missing_returns_null() + { + var (service, _) = Seeded(); + + (await service.LoadVirtualTagAsync("NOPE")).ShouldBeNull(); + } }