diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 5f2578c6..72452017 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -48,6 +48,7 @@ public static class EndpointRouteBuilderExtensions services.AddHostedService(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs index bc0050c8..9b2cc646 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IRawTreeService.cs @@ -1,5 +1,34 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +/// +/// Detached, editable projection of a raw Tag — the read side of tag editing. WP1 exposes this +/// loader; WP4 owns the create/update authoring surface and should align its input model with these +/// fields (identity is the RawPath, formed from the tag's device + optional group + name). +/// +/// The tag's stable logical id. +/// The owning device's logical id. +/// The containing tag group's logical id, or null when directly under the device. +/// The tag name (a RawPath segment). +/// The OPC UA built-in type name. +/// The tag's access-level baseline. +/// Whether writes to this tag are retry-eligible. +/// The optional poll-group id. +/// The schemaless per-driver TagConfig JSON blob. +/// The optimistic-concurrency token. +public readonly record struct RawTagEditDto( + string TagId, + string DeviceId, + string? TagGroupId, + string Name, + string DataType, + TagAccessLevel AccessLevel, + bool WriteIdempotent, + string? PollGroupId, + string TagConfig, + byte[] RowVersion); + /// /// Loads and mutates the v3 Raw project tree (/raw) — the cluster-rooted /// Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy — from the config @@ -41,4 +70,185 @@ public interface IRawTreeService /// A token to cancel the load. /// The parent's direct child nodes; empty for a leaf or an empty container. Task> LoadChildrenAsync(RawNode parent, CancellationToken ct = default); + + // --- Folder mutations --- + + /// + /// Creates a new RawFolder under a cluster ( null) or under + /// another folder. The name is validated as a RawPath segment and must be unique among its siblings + /// (case-sensitive ordinal). On success the result's CreatedId carries the new folder's + /// logical id. + /// + /// The owning cluster id. + /// The parent folder's logical id, or null for a cluster-root folder. + /// The folder name (a RawPath segment). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new RawFolderId on success. + Task CreateFolderAsync(string clusterId, string? parentFolderId, string name, CancellationToken ct = default); + + /// + /// Renames a RawFolder. Validates the new name as a RawPath segment and sibling-unique. + /// Because a folder rename changes the RawPath of every tag beneath it, the returned + /// carries advisory warnings for historized and equipment-referenced + /// tags in the folder's subtree. + /// + /// The folder's logical id. + /// The new folder name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameFolderAsync(string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes an empty RawFolder. Blocked (with the blocker named) while it still contains child + /// folders or driver instances. + /// + /// The folder's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteFolderAsync(string rawFolderId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Moves a driver instance into a folder () or to the cluster root + /// (null target). The target folder must be in the driver's cluster and the driver's name must be + /// unique among the target's driver siblings. + /// + /// The driver instance's logical id. + /// The destination folder's logical id, or null for the cluster root. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The move outcome. + Task MoveDriverToFolderAsync(string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default); + + // --- Driver mutations --- + + /// + /// Creates a minimal driver instance and its auto-created default Device so the tree has a + /// child to show. Full typed driver/device config authoring is Wave B (WP3) — this create takes the + /// driver config JSON verbatim and seeds an empty ({}) default device. The driver name is a + /// RawPath segment, unique among its cluster/folder siblings. + /// + /// The owning cluster id. + /// The containing folder's logical id, or null for a cluster-root driver. + /// The driver instance name (a RawPath segment). + /// The driver-type string (see DriverTypeNames). + /// The driver config JSON blob (opaque here; validated in Wave B). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new DriverInstanceId on success. + Task CreateDriverAsync(string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default); + + /// + /// Renames a driver instance. Validates the new name as a sibling-unique RawPath segment and returns + /// downstream-impact warnings for historized/equipment-referenced tags beneath the driver. + /// + /// The driver instance's logical id. + /// The new driver name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameDriverAsync(string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// Enables or disables a driver instance (last-write-wins on ). + /// The driver instance's logical id. + /// The new enabled state. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The update outcome. + Task SetDriverEnabledAsync(string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes a driver instance, cascading its auto-created empty devices. Blocked (with the blocking + /// device named) when any of its devices still contains tag groups or tags — remove those first. + /// + /// The driver instance's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteDriverAsync(string driverInstanceId, byte[] rowVersion, CancellationToken ct = default); + + // --- Device mutations --- + + /// + /// Creates a minimal Device under a driver. Full typed device-config authoring is Wave B + /// (WP3) — this create takes the device config JSON verbatim. The device name must be unique among + /// the driver's devices. + /// + /// The owning driver instance's logical id. + /// The device name (a RawPath segment). + /// The device config JSON blob (opaque here; validated in Wave B). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new DeviceId on success. + Task CreateDeviceAsync(string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default); + + /// + /// Renames a Device. Validates the new name as a driver-unique RawPath segment and returns + /// downstream-impact warnings for historized/equipment-referenced tags beneath the device. + /// + /// The device's logical id. + /// The new device name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameDeviceAsync(string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// Deletes a Device. Blocked (with the blocker named) while it holds tag groups or tags. + /// The device's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteDeviceAsync(string deviceId, byte[] rowVersion, CancellationToken ct = default); + + // --- TagGroup mutations --- + + /// + /// Creates a new TagGroup under a device ( null) or under + /// another group. The name is validated as a RawPath segment, unique among its siblings. + /// + /// The owning device's logical id. + /// The parent group's logical id, or null for a device-root group. + /// The group name (a RawPath segment). + /// A token to cancel the operation. + /// The create outcome; CreatedId is the new TagGroupId on success. + Task CreateTagGroupAsync(string deviceId, string? parentGroupId, string name, CancellationToken ct = default); + + /// + /// Renames a TagGroup. Validates the new name as a sibling-unique RawPath segment and returns + /// downstream-impact warnings for historized/equipment-referenced tags beneath the group. + /// + /// The group's logical id. + /// The new group name. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The rename outcome with any downstream-impact warnings. + Task RenameTagGroupAsync(string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default); + + /// Deletes a TagGroup. Blocked (with the blocker named) while it holds child groups or tags. + /// The group's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteTagGroupAsync(string tagGroupId, byte[] rowVersion, CancellationToken ct = default); + + // --- Tag mutations / projections (create + update are WP4) --- + + /// + /// Deletes a raw Tag. Blocked while any UnsTagReference points at it; the failure + /// message names the referencing equipment(s) so the operator can remove the reference(s) first. + /// + /// The tag's logical id. + /// The optimistic-concurrency token echoed from the loaded node. + /// A token to cancel the operation. + /// The delete outcome. + Task DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Loads a raw tag's editable fields as a detached projection. WP1 provides the read-only projection; + /// the create/update authoring surface is WP4 (which should align its input shape with + /// ). + /// + /// The tag's logical id. + /// A token to cancel the load. + /// The tag's editable projection, or null when the tag does not exist. + Task LoadTagForEditAsync(string tagId, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeAssembly.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeAssembly.cs new file mode 100644 index 00000000..53d9c6cc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeAssembly.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Pure, EF-free assembly of the eager top of the Raw project tree (/raw) — Enterprise → Cluster — +/// from flat cluster rows. The peer of for the Raw subtree. Everything below +/// a cluster loads lazily via , so this helper only nests +/// clusters under their enterprise label and stamps each cluster with its root-level child count and the +/// lazy-load flag. Empty clusters are retained so they stay visible and authorable. +/// +public static class RawTreeAssembly +{ + /// + /// Builds the Enterprise→Cluster roots. Ordering is deterministic and ordinal at both levels. Each + /// cluster is marked and carries the supplied root child count + /// (its root folders + cluster-root drivers). + /// + /// The flat cluster rows to nest under their enterprise label. + /// Per-cluster count of root folders + cluster-root drivers (badge). + /// The assembled enterprise root nodes, populated down to (but not through) their clusters. + public static IReadOnlyList BuildRoots( + IReadOnlyList clusters, + IReadOnlyDictionary rootChildCounts) + { + return clusters + .GroupBy(c => c.Enterprise, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(entGroup => + { + var ent = new RawNode + { + Kind = RawNodeKind.Enterprise, + Key = $"ent:{entGroup.Key}", + DisplayName = entGroup.Key, + ClusterId = null, + EntityId = null, + HasLazyChildren = false, + }; + + var clusterNodes = entGroup + .OrderBy(c => c.Name, StringComparer.Ordinal) + .ThenBy(c => c.ClusterId, StringComparer.Ordinal) + .Select(c => new RawNode + { + Kind = RawNodeKind.Cluster, + Key = $"clu:{c.ClusterId}", + DisplayName = string.IsNullOrEmpty(c.Site) ? c.Name : $"{c.Site} ({c.Name})", + ClusterId = c.ClusterId, + // A cluster's own logical id IS its ClusterId — EntityId mirrors it for uniform navigation. + EntityId = c.ClusterId, + HasLazyChildren = true, + ChildCount = rootChildCounts.GetValueOrDefault(c.ClusterId), + }) + .ToList(); + + ent.Children.AddRange(clusterNodes); + ent.ChildCount = ent.Children.Count; + return ent; + }) + .ToList(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs new file mode 100644 index 00000000..d5370f59 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -0,0 +1,1019 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Default . Reads the Raw-tree structural rows with untracked queries, +/// lazily materialising one level per expand, and applies structural mutations (create/rename/delete/ +/// move) with last-write-wins optimistic concurrency on RowVersion — the same pattern +/// uses for the UNS subtree. Integrity rules (sibling-name uniqueness, +/// non-empty-container delete blocks, referenced-tag delete blocks) are pre-checked with user-readable +/// errors so the refusal is deterministic even under the EF InMemory provider (which does not enforce +/// the FK Restrict / filtered-unique indexes the real SQL schema carries). +/// +/// +/// Each call creates and disposes its own context via the pooled factory, so the service is safe to +/// register as Scoped and used per Blazor circuit. +/// +public sealed class RawTreeService(IDbContextFactory dbFactory) : IRawTreeService +{ + // --------------------------------------------------------------------------------------------- + // Read surface + // --------------------------------------------------------------------------------------------- + + /// + public async Task> LoadRootsAsync(CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var clusters = await db.ServerClusters + .AsNoTracking() + .Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name)) + .ToListAsync(ct); + + // Per-cluster root child count = root folders (ParentRawFolderId == null) + cluster-root drivers + // (RawFolderId == null), for the cluster badge. + var rootFolderCounts = (await db.RawFolders.AsNoTracking() + .Where(f => f.ParentRawFolderId == null) + .GroupBy(f => f.ClusterId) + .Select(g => new { ClusterId = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal); + + var rootDriverCounts = (await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId == null) + .GroupBy(d => d.ClusterId) + .Select(g => new { ClusterId = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal); + + var rootChildCounts = new Dictionary(StringComparer.Ordinal); + foreach (var c in clusters) + { + rootChildCounts[c.ClusterId] = + rootFolderCounts.GetValueOrDefault(c.ClusterId) + rootDriverCounts.GetValueOrDefault(c.ClusterId); + } + + return RawTreeAssembly.BuildRoots(clusters, rootChildCounts); + } + + /// + public async Task> LoadChildrenAsync(RawNode parent, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(parent); + if (parent.EntityId is null) + { + return Array.Empty(); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + return parent.Kind switch + { + RawNodeKind.Cluster => await LoadClusterChildrenAsync(db, parent.EntityId, ct), + RawNodeKind.Folder => await LoadFolderChildrenAsync(db, parent.ClusterId, parent.EntityId, ct), + RawNodeKind.Driver => await LoadDriverChildrenAsync(db, parent, ct), + RawNodeKind.Device => await LoadDeviceChildrenAsync(db, parent, ct), + RawNodeKind.TagGroup => await LoadTagGroupChildrenAsync(db, parent, ct), + _ => Array.Empty(), // Tag (leaf) / Enterprise + }; + } + + private static async Task> LoadClusterChildrenAsync( + OtOpcUaConfigDbContext db, string clusterId, CancellationToken ct) + { + var folders = await db.RawFolders.AsNoTracking() + .Where(f => f.ClusterId == clusterId && f.ParentRawFolderId == null) + .OrderBy(f => f.SortOrder).ThenBy(f => f.Name) + .ToListAsync(ct); + + var drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == clusterId && d.RawFolderId == null) + .OrderBy(d => d.Name) + .ToListAsync(ct); + + var nodes = new List(folders.Count + drivers.Count); + nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, folders, ct)); + nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct)); + return nodes; + } + + private static async Task> LoadFolderChildrenAsync( + OtOpcUaConfigDbContext db, string? clusterId, string folderId, CancellationToken ct) + { + // Resolve the cluster from the folder itself when the parent node did not carry it. + clusterId ??= await db.RawFolders.AsNoTracking() + .Where(f => f.RawFolderId == folderId).Select(f => f.ClusterId).FirstOrDefaultAsync(ct); + + var subFolders = await db.RawFolders.AsNoTracking() + .Where(f => f.ParentRawFolderId == folderId) + .OrderBy(f => f.SortOrder).ThenBy(f => f.Name) + .ToListAsync(ct); + + var drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId == folderId) + .OrderBy(d => d.Name) + .ToListAsync(ct); + + var nodes = new List(subFolders.Count + drivers.Count); + nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, subFolders, ct)); + nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct)); + return nodes; + } + + private static async Task> LoadDriverChildrenAsync( + OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) + { + var driverId = parent.EntityId!; + // The driver node carries its own DriverType; fall back to a lookup if absent. + var driverType = parent.DriverType + ?? await db.DriverInstances.AsNoTracking() + .Where(d => d.DriverInstanceId == driverId).Select(d => d.DriverType).FirstOrDefaultAsync(ct); + + var devices = await db.Devices.AsNoTracking() + .Where(dev => dev.DriverInstanceId == driverId) + .OrderBy(dev => dev.Name) + .ToListAsync(ct); + + var deviceIds = devices.Select(d => d.DeviceId).ToList(); + var groupCounts = await CountRootGroupsByDeviceAsync(db, deviceIds, ct); + var tagCounts = await CountRootTagsByDeviceAsync(db, deviceIds, ct); + + return devices.Select(dev => new RawNode + { + Kind = RawNodeKind.Device, + Key = $"dev:{dev.DeviceId}", + DisplayName = dev.Name, + ClusterId = parent.ClusterId, + EntityId = dev.DeviceId, + DriverType = driverType, + Enabled = dev.Enabled, + HasLazyChildren = true, + ChildCount = groupCounts.GetValueOrDefault(dev.DeviceId) + tagCounts.GetValueOrDefault(dev.DeviceId), + RowVersion = dev.RowVersion, + }).ToList(); + } + + private static async Task> LoadDeviceChildrenAsync( + OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) + { + var deviceId = parent.EntityId!; + + var groups = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == deviceId && g.ParentTagGroupId == null) + .OrderBy(g => g.SortOrder).ThenBy(g => g.Name) + .ToListAsync(ct); + + var tags = await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == deviceId && t.TagGroupId == null) + .OrderBy(t => t.Name) + .ToListAsync(ct); + + var nodes = new List(groups.Count + tags.Count); + nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, groups, ct)); + nodes.AddRange(tags.Select(t => BuildTagNode(parent, t))); + return nodes; + } + + private static async Task> LoadTagGroupChildrenAsync( + OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct) + { + var groupId = parent.EntityId!; + + var subGroups = await db.TagGroups.AsNoTracking() + .Where(g => g.ParentTagGroupId == groupId) + .OrderBy(g => g.SortOrder).ThenBy(g => g.Name) + .ToListAsync(ct); + + var tags = await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId == groupId) + .OrderBy(t => t.Name) + .ToListAsync(ct); + + var nodes = new List(subGroups.Count + tags.Count); + nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, subGroups, ct)); + nodes.AddRange(tags.Select(t => BuildTagNode(parent, t))); + return nodes; + } + + // --- Node builders (compute per-node child counts in bulk) --- + + private static async Task> BuildFolderNodesAsync( + OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList folders, CancellationToken ct) + { + if (folders.Count == 0) return Array.Empty(); + var ids = folders.Select(f => f.RawFolderId).ToList(); + + var subFolderCounts = (await db.RawFolders.AsNoTracking() + .Where(f => f.ParentRawFolderId != null && ids.Contains(f.ParentRawFolderId)) + .GroupBy(f => f.ParentRawFolderId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + var driverCounts = (await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId != null && ids.Contains(d.RawFolderId)) + .GroupBy(d => d.RawFolderId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + return folders.Select(f => new RawNode + { + Kind = RawNodeKind.Folder, + Key = $"fld:{f.RawFolderId}", + DisplayName = f.Name, + ClusterId = clusterId, + EntityId = f.RawFolderId, + HasLazyChildren = true, + ChildCount = subFolderCounts.GetValueOrDefault(f.RawFolderId) + driverCounts.GetValueOrDefault(f.RawFolderId), + RowVersion = f.RowVersion, + }).ToList(); + } + + private static async Task> BuildDriverNodesAsync( + OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList drivers, CancellationToken ct) + { + if (drivers.Count == 0) return Array.Empty(); + var ids = drivers.Select(d => d.DriverInstanceId).ToList(); + + var deviceCounts = (await db.Devices.AsNoTracking() + .Where(dev => ids.Contains(dev.DriverInstanceId)) + .GroupBy(dev => dev.DriverInstanceId) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + return drivers.Select(d => new RawNode + { + Kind = RawNodeKind.Driver, + Key = $"drv:{d.DriverInstanceId}", + DisplayName = d.Name, + ClusterId = clusterId, + EntityId = d.DriverInstanceId, + DriverType = d.DriverType, + Enabled = d.Enabled, + HasLazyChildren = true, + ChildCount = deviceCounts.GetValueOrDefault(d.DriverInstanceId), + RowVersion = d.RowVersion, + }).ToList(); + } + + private static async Task> BuildTagGroupNodesAsync( + OtOpcUaConfigDbContext db, RawNode parent, IReadOnlyList groups, CancellationToken ct) + { + if (groups.Count == 0) return Array.Empty(); + var ids = groups.Select(g => g.TagGroupId).ToList(); + + var subGroupCounts = (await db.TagGroups.AsNoTracking() + .Where(g => g.ParentTagGroupId != null && ids.Contains(g.ParentTagGroupId)) + .GroupBy(g => g.ParentTagGroupId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + var tagCounts = (await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId != null && ids.Contains(t.TagGroupId)) + .GroupBy(t => t.TagGroupId!) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + + return groups.Select(g => new RawNode + { + Kind = RawNodeKind.TagGroup, + Key = $"grp:{g.TagGroupId}", + DisplayName = g.Name, + ClusterId = parent.ClusterId, + EntityId = g.TagGroupId, + DriverType = parent.DriverType, + HasLazyChildren = true, + ChildCount = subGroupCounts.GetValueOrDefault(g.TagGroupId) + tagCounts.GetValueOrDefault(g.TagGroupId), + RowVersion = g.RowVersion, + }).ToList(); + } + + private static RawNode BuildTagNode(RawNode parent, Tag t) => new() + { + Kind = RawNodeKind.Tag, + Key = $"tag:{t.TagId}", + DisplayName = t.Name, + ClusterId = parent.ClusterId, + EntityId = t.TagId, + DriverType = parent.DriverType, + HasLazyChildren = false, + ChildCount = 0, + RowVersion = t.RowVersion, + }; + + private static async Task> CountRootGroupsByDeviceAsync( + OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) + { + if (deviceIds.Count == 0) return new Dictionary(StringComparer.Ordinal); + return (await db.TagGroups.AsNoTracking() + .Where(g => g.ParentTagGroupId == null && deviceIds.Contains(g.DeviceId)) + .GroupBy(g => g.DeviceId) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + } + + private static async Task> CountRootTagsByDeviceAsync( + OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) + { + if (deviceIds.Count == 0) return new Dictionary(StringComparer.Ordinal); + return (await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId == null && deviceIds.Contains(t.DeviceId)) + .GroupBy(t => t.DeviceId) + .Select(g => new { Id = g.Key, Count = g.Count() }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal); + } + + // --------------------------------------------------------------------------------------------- + // Folder mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateFolderAsync( + string clusterId, string? parentFolderId, string name, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct)) + return new UnsMutationResult(false, $"Cluster '{clusterId}' not found."); + + if (parentFolderId is not null) + { + var parent = await db.RawFolders.AsNoTracking() + .FirstOrDefaultAsync(f => f.RawFolderId == parentFolderId, ct); + if (parent is null) return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' not found."); + if (parent.ClusterId != clusterId) + return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' is in a different cluster."); + } + + if (await db.RawFolders.AnyAsync( + f => f.ClusterId == clusterId && f.ParentRawFolderId == parentFolderId && f.Name == name, ct)) + return new UnsMutationResult(false, $"A folder named '{name}' already exists here."); + + var id = NewId("RF"); + db.RawFolders.Add(new RawFolder + { + RawFolderId = id, + ClusterId = clusterId, + ParentRawFolderId = parentFolderId, + Name = name, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, id); + } + + /// + public async Task RenameFolderAsync( + string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.RawFolders.AnyAsync( + f => f.ClusterId == entity.ClusterId && f.ParentRawFolderId == entity.ParentRawFolderId + && f.Name == newName && f.RawFolderId != rawFolderId, ct)) + return RawRenameResult.Failure($"A folder named '{newName}' already exists here."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathFolderAsync(db, rawFolderId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "folder", ct); + } + + /// + public async Task DeleteFolderAsync( + string rawFolderId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + if (await db.RawFolders.AnyAsync(f => f.ParentRawFolderId == rawFolderId, ct)) + return new UnsMutationResult(false, + $"Cannot delete folder '{entity.Name}': it still contains sub-folders — remove or move them first."); + + if (await db.DriverInstances.AnyAsync(d => d.RawFolderId == rawFolderId, ct)) + return new UnsMutationResult(false, + $"Cannot delete folder '{entity.Name}': it still contains driver instances — remove or move them first."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.RawFolders.Remove(entity); + return await SaveDeleteAsync(db, "folder", ct); + } + + /// + public async Task MoveDriverToFolderAsync( + string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (driver is null) return new UnsMutationResult(false, "Row no longer exists."); + + if (targetFolderId is not null) + { + var folder = await db.RawFolders.AsNoTracking() + .FirstOrDefaultAsync(f => f.RawFolderId == targetFolderId, ct); + if (folder is null) return new UnsMutationResult(false, $"Target folder '{targetFolderId}' not found."); + if (folder.ClusterId != driver.ClusterId) + return new UnsMutationResult(false, + $"Target folder '{folder.Name}' is in cluster '{folder.ClusterId}' but the driver is in cluster '{driver.ClusterId}'."); + } + + if (await db.DriverInstances.AnyAsync( + d => d.ClusterId == driver.ClusterId && d.RawFolderId == targetFolderId + && d.Name == driver.Name && d.DriverInstanceId != driverInstanceId, ct)) + return new UnsMutationResult(false, + $"A driver named '{driver.Name}' already exists in the target location."); + + db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion; + driver.RawFolderId = targetFolderId; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this driver while you were editing. Reload to see the latest values."); + } + } + + // --------------------------------------------------------------------------------------------- + // Driver mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateDriverAsync( + string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + if (string.IsNullOrWhiteSpace(driverType)) + return new UnsMutationResult(false, "Pick a driver type."); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct)) + return new UnsMutationResult(false, $"Cluster '{clusterId}' not found."); + + if (folderId is not null) + { + var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == folderId, ct); + if (folder is null) return new UnsMutationResult(false, $"Folder '{folderId}' not found."); + if (folder.ClusterId != clusterId) + return new UnsMutationResult(false, $"Folder '{folderId}' is in a different cluster."); + } + + if (await db.DriverInstances.AnyAsync( + d => d.ClusterId == clusterId && d.RawFolderId == folderId && d.Name == name, ct)) + return new UnsMutationResult(false, $"A driver named '{name}' already exists here."); + + var driverId = NewId("DRV"); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = driverId, + ClusterId = clusterId, + RawFolderId = folderId, + Name = name, + DriverType = driverType, + DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson, + }); + + // Auto-create the default device so a freshly-created driver has a child to show. Wave B (WP3) + // refines device/driver config authoring; here the default device carries an empty config. + db.Devices.Add(new Device + { + DeviceId = NewId("DEV"), + DriverInstanceId = driverId, + Name = "Device1", + DeviceConfig = "{}", + }); + + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, driverId); + } + + /// + public async Task RenameDriverAsync( + string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.DriverInstances.AnyAsync( + d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId + && d.Name == newName && d.DriverInstanceId != driverInstanceId, ct)) + return RawRenameResult.Failure($"A driver named '{newName}' already exists here."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDriverAsync(db, driverInstanceId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "driver", ct); + } + + /// + public async Task SetDriverEnabledAsync( + string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (entity is null) return new UnsMutationResult(false, "Row no longer exists."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Enabled = enabled; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this driver while you were editing."); + } + } + + /// + public async Task DeleteDriverAsync( + string driverInstanceId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct); + if (driver is null) return new UnsMutationResult(true, null); + + var devices = await db.Devices.Where(dev => dev.DriverInstanceId == driverInstanceId).ToListAsync(ct); + + // A driver always has ≥1 device, so "has devices" cannot be the block. Instead: cascade the + // (empty) auto-devices, but refuse — naming the offending device — when any device still holds + // tag groups or tags, so tag authoring is never silently discarded. + foreach (var dev in devices) + { + if (await db.TagGroups.AnyAsync(g => g.DeviceId == dev.DeviceId, ct) + || await db.Tags.AnyAsync(t => t.DeviceId == dev.DeviceId, ct)) + { + return new UnsMutationResult(false, + $"Cannot delete driver '{driver.Name}': device '{dev.Name}' still contains tag groups or tags — remove them first."); + } + } + + db.Devices.RemoveRange(devices); + db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.DriverInstances.Remove(driver); + return await SaveDeleteAsync(db, "driver", ct); + } + + // --------------------------------------------------------------------------------------------- + // Device mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateDeviceAsync( + string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct)) + return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found."); + + if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct)) + return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver."); + + var id = NewId("DEV"); + db.Devices.Add(new Device + { + DeviceId = id, + DriverInstanceId = driverInstanceId, + Name = name, + DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, id); + } + + /// + public async Task RenameDeviceAsync( + string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.Devices.AnyAsync( + dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == newName && dev.DeviceId != deviceId, ct)) + return RawRenameResult.Failure($"A device named '{newName}' already exists on this driver."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDeviceAsync(db, deviceId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "device", ct); + } + + /// + public async Task DeleteDeviceAsync( + string deviceId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + if (await db.TagGroups.AnyAsync(g => g.DeviceId == deviceId, ct)) + return new UnsMutationResult(false, + $"Cannot delete device '{entity.Name}': it still contains tag groups — remove them first."); + + if (await db.Tags.AnyAsync(t => t.DeviceId == deviceId, ct)) + return new UnsMutationResult(false, + $"Cannot delete device '{entity.Name}': it still contains tags — remove them first."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.Devices.Remove(entity); + return await SaveDeleteAsync(db, "device", ct); + } + + // --------------------------------------------------------------------------------------------- + // TagGroup mutations + // --------------------------------------------------------------------------------------------- + + /// + public async Task CreateTagGroupAsync( + string deviceId, string? parentGroupId, string name, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(name); + if (nameError is not null) return new UnsMutationResult(false, nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct)) + return new UnsMutationResult(false, $"Device '{deviceId}' not found."); + + if (parentGroupId is not null) + { + var parent = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == parentGroupId, ct); + if (parent is null) return new UnsMutationResult(false, $"Parent group '{parentGroupId}' not found."); + if (parent.DeviceId != deviceId) + return new UnsMutationResult(false, $"Parent group '{parentGroupId}' is on a different device."); + } + + if (await db.TagGroups.AnyAsync( + g => g.DeviceId == deviceId && g.ParentTagGroupId == parentGroupId && g.Name == name, ct)) + return new UnsMutationResult(false, $"A tag group named '{name}' already exists here."); + + var id = NewId("TG"); + db.TagGroups.Add(new TagGroup + { + TagGroupId = id, + DeviceId = deviceId, + ParentTagGroupId = parentGroupId, + Name = name, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null, id); + } + + /// + public async Task RenameTagGroupAsync( + string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default) + { + var nameError = RawPaths.ValidateSegment(newName); + if (nameError is not null) return RawRenameResult.Failure(nameError); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (entity is null) return RawRenameResult.Failure("Row no longer exists."); + + if (await db.TagGroups.AnyAsync( + g => g.DeviceId == entity.DeviceId && g.ParentTagGroupId == entity.ParentTagGroupId + && g.Name == newName && g.TagGroupId != tagGroupId, ct)) + return RawRenameResult.Failure($"A tag group named '{newName}' already exists here."); + + var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathGroupAsync(db, tagGroupId, ct), ct); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.Name = newName; + return await SaveRenameAsync(db, warnings, "tag group", ct); + } + + /// + public async Task DeleteTagGroupAsync( + string tagGroupId, byte[] rowVersion, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (entity is null) return new UnsMutationResult(true, null); + + if (await db.TagGroups.AnyAsync(g => g.ParentTagGroupId == tagGroupId, ct)) + return new UnsMutationResult(false, + $"Cannot delete tag group '{entity.Name}': it still contains sub-groups — remove them first."); + + if (await db.Tags.AnyAsync(t => t.TagGroupId == tagGroupId, ct)) + return new UnsMutationResult(false, + $"Cannot delete tag group '{entity.Name}': it still contains tags — remove them first."); + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.TagGroups.Remove(entity); + return await SaveDeleteAsync(db, "tag group", ct); + } + + // --------------------------------------------------------------------------------------------- + // Tag delete + edit projection (create/update are WP4) + // --------------------------------------------------------------------------------------------- + + /// + public async Task DeleteTagAsync(string tagId, 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(true, null); + + // Blocked while any UNS equipment references this raw tag — name the referencing equipment(s). + var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() + .Where(r => r.TagId == tagId) + .Select(r => r.EquipmentId) + .Distinct() + .ToListAsync(ct); + + if (referencingEquipmentIds.Count > 0) + { + var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct); + return new UnsMutationResult(false, + $"Cannot delete tag '{entity.Name}': it is referenced by equipment {display} — remove the UNS reference(s) first."); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.Tags.Remove(entity); + return await SaveDeleteAsync(db, "tag", ct); + } + + /// + public async Task LoadTagForEditAsync(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 RawTagEditDto( + t.TagId, + t.DeviceId, + t.TagGroupId, + t.Name, + t.DataType, + t.AccessLevel, + t.WriteIdempotent, + t.PollGroupId, + t.TagConfig, + t.RowVersion)) + .FirstOrDefaultAsync(ct); + } + + // --------------------------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------------------------- + + /// Generates a system id of the form {prefix}-{12 hex} (≤ the 64-char id limit). + private static string NewId(string prefix) => $"{prefix}-{Guid.NewGuid().ToString("N")[..12]}"; + + /// Commits a rename, translating a concurrency clash into a friendly failure. + private static async Task SaveRenameAsync( + OtOpcUaConfigDbContext db, IReadOnlyList warnings, string noun, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + return RawRenameResult.Success(warnings); + } + catch (DbUpdateConcurrencyException) + { + return RawRenameResult.Failure($"Another user changed this {noun} while you were editing. Reload to see the latest values."); + } + } + + /// Commits a delete, translating a concurrency clash / stray FK block into a friendly failure. + private static async Task SaveDeleteAsync( + OtOpcUaConfigDbContext db, string noun, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it."); + } + catch (Exception ex) + { + // Defensive net for a real SQL Server where a child slipped past the pre-check (race). + return new UnsMutationResult(false, $"Delete failed: {ex.Message}."); + } + } + + /// + /// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and + /// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname / + /// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here. + /// + private static async Task> BuildRenameWarningsAsync( + OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct) + { + var warnings = new List(); + if (tags.Count == 0) return warnings; + + var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized); + if (historized > 0) + { + warnings.Add(historized == 1 + ? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path." + : $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths."); + } + + var tagIds = tags.Select(t => t.TagId).ToList(); + var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking() + .Where(r => tagIds.Contains(r.TagId)) + .Select(r => r.EquipmentId) + .Distinct() + .ToListAsync(ct); + + if (referencingEquipmentIds.Count > 0) + { + var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct); + warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename."); + } + + // Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append + // a "N script(s) reference tags beneath this node" warning to this same list. + + return warnings; + } + + /// Renders a friendly "Name (Id), …" list for the referencing equipment ids. + private static async Task DescribeEquipmentAsync( + OtOpcUaConfigDbContext db, IReadOnlyList equipmentIds, CancellationToken ct) + { + var names = (await db.Equipment.AsNoTracking() + .Where(e => equipmentIds.Contains(e.EquipmentId)) + .Select(e => new { e.EquipmentId, e.Name }) + .ToListAsync(ct)) + .ToDictionary(x => x.EquipmentId, x => x.Name, StringComparer.Ordinal); + + return string.Join(", ", equipmentIds + .OrderBy(id => id, StringComparer.Ordinal) + .Select(id => names.TryGetValue(id, out var n) ? $"'{n}' ({id})" : $"'{id}'")); + } + + // --- Subtree tag collectors (walk the in-DB subtree for rename impact) --- + + private static async Task> CollectTagsBeneathFolderAsync( + OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct) + { + var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct); + if (folder is null) return Array.Empty<(string, string)>(); + + // All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId. + var clusterFolders = await db.RawFolders.AsNoTracking() + .Where(f => f.ClusterId == folder.ClusterId) + .Select(f => new { f.RawFolderId, f.ParentRawFolderId }) + .ToListAsync(ct); + + var childrenByParent = clusterFolders + .Where(f => f.ParentRawFolderId is not null) + .GroupBy(f => f.ParentRawFolderId!, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => x.RawFolderId).ToList(), StringComparer.Ordinal); + + var folderIds = DescendantsInclusive(rawFolderId, childrenByParent); + + var driverIds = await db.DriverInstances.AsNoTracking() + .Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId)) + .Select(d => d.DriverInstanceId) + .ToListAsync(ct); + if (driverIds.Count == 0) return Array.Empty<(string, string)>(); + + var deviceIds = await db.Devices.AsNoTracking() + .Where(dev => driverIds.Contains(dev.DriverInstanceId)) + .Select(dev => dev.DeviceId) + .ToListAsync(ct); + if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + + return await TagsForDevicesAsync(db, deviceIds, ct); + } + + private static async Task> CollectTagsBeneathDriverAsync( + OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct) + { + var deviceIds = await db.Devices.AsNoTracking() + .Where(dev => dev.DriverInstanceId == driverInstanceId) + .Select(dev => dev.DeviceId) + .ToListAsync(ct); + if (deviceIds.Count == 0) return Array.Empty<(string, string)>(); + return await TagsForDevicesAsync(db, deviceIds, ct); + } + + private static async Task> CollectTagsBeneathDeviceAsync( + OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct) + { + return (await db.Tags.AsNoTracking() + .Where(t => t.DeviceId == deviceId) + .Select(t => new { t.TagId, t.TagConfig }) + .ToListAsync(ct)) + .Select(t => (t.TagId, t.TagConfig)) + .ToList(); + } + + private static async Task> CollectTagsBeneathGroupAsync( + OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct) + { + var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct); + if (group is null) return Array.Empty<(string, string)>(); + + var deviceGroups = await db.TagGroups.AsNoTracking() + .Where(g => g.DeviceId == group.DeviceId) + .Select(g => new { g.TagGroupId, g.ParentTagGroupId }) + .ToListAsync(ct); + + var childrenByParent = deviceGroups + .Where(g => g.ParentTagGroupId is not null) + .GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal); + + var groupIds = DescendantsInclusive(tagGroupId, childrenByParent); + + return (await db.Tags.AsNoTracking() + .Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId)) + .Select(t => new { t.TagId, t.TagConfig }) + .ToListAsync(ct)) + .Select(t => (t.TagId, t.TagConfig)) + .ToList(); + } + + private static async Task> TagsForDevicesAsync( + OtOpcUaConfigDbContext db, IReadOnlyList deviceIds, CancellationToken ct) + { + return (await db.Tags.AsNoTracking() + .Where(t => deviceIds.Contains(t.DeviceId)) + .Select(t => new { t.TagId, t.TagConfig }) + .ToListAsync(ct)) + .Select(t => (t.TagId, t.TagConfig)) + .ToList(); + } + + /// + /// Returns plus all its transitive descendants, using a + /// parent→children adjacency map. Iterative (stack-based) so deep trees can't blow the call stack. + /// + private static HashSet DescendantsInclusive( + string rootId, IReadOnlyDictionary> childrenByParent) + { + var result = new HashSet(StringComparer.Ordinal); + var stack = new Stack(); + stack.Push(rootId); + while (stack.Count > 0) + { + var id = stack.Pop(); + if (!result.Add(id)) continue; // guard against cycles / double-visits + if (childrenByParent.TryGetValue(id, out var kids)) + { + foreach (var kid in kids) stack.Push(kid); + } + } + return result; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs new file mode 100644 index 00000000..4657e5a8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeServiceTests.cs @@ -0,0 +1,348 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Covers — the B2-WP1 Raw-tree data layer: lazy children per level, +/// create/rename/delete/move happy paths and guards (sibling-name collision, non-empty-container delete +/// blocks naming the blocker, referenced-tag delete blocks naming the equipment), and rename warnings. +/// +/// As with the UnsTreeService suite, the EF InMemory provider does not enforce RowVersion +/// concurrency tokens, so the DbUpdateConcurrencyException branch is not directly exercised; the +/// concurrent-modification failure is instead covered via the "row vanished under a stale handle" path +/// (another actor deletes the row before the rename commits). +/// +/// +[Trait("Category", "Unit")] +public sealed class RawTreeServiceTests +{ + private static (RawTreeService Service, string DbName) Seeded() + { + var name = RawTreeTestDb.SeedFresh(); + return (new RawTreeService(RawTreeTestDb.Factory(name)), name); + } + + private static byte[] RowVersionOf( + string dbName, Func selector) + { + using var db = RawTreeTestDb.CreateNamed(dbName); + return selector(db); + } + + // ----------------------------------------------------------------------------------- Read layer + + [Fact] + public async Task LoadRoots_yields_enterprise_and_cluster_with_lazy_flag_and_root_count() + { + var (service, _) = Seeded(); + + var roots = await service.LoadRootsAsync(); + + var ent = roots.ShouldHaveSingleItem(); + ent.Kind.ShouldBe(RawNodeKind.Enterprise); + ent.DisplayName.ShouldBe("zb"); + + var cluster = ent.Children.ShouldHaveSingleItem(); + cluster.Kind.ShouldBe(RawNodeKind.Cluster); + cluster.EntityId.ShouldBe(RawTreeTestDb.ClusterId); + cluster.HasLazyChildren.ShouldBeTrue(); + cluster.Children.ShouldBeEmpty(); // clusters load lazily + cluster.ChildCount.ShouldBe(2); // 1 root folder + 1 cluster-root driver + } + + [Fact] + public async Task LoadChildren_of_cluster_yields_root_folders_and_root_drivers() + { + var (service, _) = Seeded(); + var cluster = (await service.LoadRootsAsync()).Single().Children.Single(); + + var children = await service.LoadChildrenAsync(cluster); + + var folder = children.Single(n => n.Kind == RawNodeKind.Folder); + folder.DisplayName.ShouldBe("PLCs"); + folder.EntityId.ShouldBe(RawTreeTestDb.RootFolderId); + folder.HasLazyChildren.ShouldBeTrue(); + folder.ChildCount.ShouldBe(1); // the foldered driver + + var driver = children.Single(n => n.Kind == RawNodeKind.Driver); + driver.DisplayName.ShouldBe("s7-1"); + driver.DriverType.ShouldBe("S7"); + driver.ChildCount.ShouldBe(1); // its default device + } + + [Fact] + public async Task LoadChildren_propagates_driver_type_down_to_device_group_and_tag() + { + var (service, _) = Seeded(); + var cluster = (await service.LoadRootsAsync()).Single().Children.Single(); + var folder = (await service.LoadChildrenAsync(cluster)).Single(n => n.Kind == RawNodeKind.Folder); + + var driver = (await service.LoadChildrenAsync(folder)).ShouldHaveSingleItem(); + driver.Kind.ShouldBe(RawNodeKind.Driver); + driver.DriverType.ShouldBe("Modbus"); + + var device = (await service.LoadChildrenAsync(driver)).ShouldHaveSingleItem(); + device.Kind.ShouldBe(RawNodeKind.Device); + device.DriverType.ShouldBe("Modbus"); // propagated + device.ChildCount.ShouldBe(1); // one root group (tags live under the group) + + var group = (await service.LoadChildrenAsync(device)).ShouldHaveSingleItem(); + group.Kind.ShouldBe(RawNodeKind.TagGroup); + group.DriverType.ShouldBe("Modbus"); // propagated + group.ChildCount.ShouldBe(2); // two tags + + var tags = await service.LoadChildrenAsync(group); + tags.Count.ShouldBe(2); + tags.ShouldAllBe(t => t.Kind == RawNodeKind.Tag); + tags.ShouldAllBe(t => !t.HasLazyChildren); + tags.ShouldAllBe(t => t.DriverType == "Modbus"); // propagated + (await service.LoadChildrenAsync(tags[0])).ShouldBeEmpty(); // leaf + } + + // -------------------------------------------------------------------------------- Create/happy + + [Fact] + public async Task CreateFolder_then_CreateDriver_autocreates_default_device() + { + var (service, dbName) = Seeded(); + + var folder = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "Cell-B"); + folder.Ok.ShouldBeTrue(); + folder.CreatedId.ShouldNotBeNull(); + + var driver = await service.CreateDriverAsync(RawTreeTestDb.ClusterId, folder.CreatedId, "abcip-1", "AbCip", "{}"); + driver.Ok.ShouldBeTrue(); + + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Devices.Count(d => d.DriverInstanceId == driver.CreatedId).ShouldBe(1); // default device auto-created + } + + [Fact] + public async Task CreateFolder_rejects_sibling_name_collision() + { + var (service, _) = Seeded(); + + var dup = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "PLCs"); // already a root folder + dup.Ok.ShouldBeFalse(); + dup.Error!.ShouldContain("already exists"); + } + + [Fact] + public async Task CreateFolder_rejects_invalid_segment() + { + var (service, _) = Seeded(); + + var bad = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "a/b"); + bad.Ok.ShouldBeFalse(); + bad.Error!.ShouldContain("/"); + } + + [Fact] + public async Task CreateTagGroup_rejects_sibling_collision() + { + var (service, _) = Seeded(); + + var dup = await service.CreateTagGroupAsync(RawTreeTestDb.FolderedDeviceId, null, "Motors"); + dup.Ok.ShouldBeFalse(); + dup.Error!.ShouldContain("already exists"); + } + + // ------------------------------------------------------------------------------- Rename + warn + + [Fact] + public async Task RenameFolder_warns_about_historized_tag_beneath() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + var result = await service.RenameFolderAsync(RawTreeTestDb.RootFolderId, "PLC", rv); + + result.Ok.ShouldBeTrue(); + result.Warnings.ShouldContain(w => w.Contains("historized")); + } + + [Fact] + public async Task RenameDriver_warns_about_equipment_reference_beneath() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId).RowVersion); + + var result = await service.RenameDriverAsync(RawTreeTestDb.RootDriverId, "s7-main", rv); + + result.Ok.ShouldBeTrue(); + result.Warnings.ShouldContain(w => w.Contains(RawTreeTestDb.EquipmentName)); + } + + [Fact] + public async Task RenameFolder_rejects_sibling_collision() + { + var (service, dbName) = Seeded(); + await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "Spare"); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + var result = await service.RenameFolderAsync(RawTreeTestDb.RootFolderId, "Spare", rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("already exists"); + } + + // -------------------------------------------------------------------------------- Delete blocks + + [Fact] + public async Task DeleteFolder_blocked_by_contained_driver_names_the_blocker() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + var result = await service.DeleteFolderAsync(RawTreeTestDb.RootFolderId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("driver"); + result.Error!.ShouldContain("PLCs"); + } + + [Fact] + public async Task DeleteDevice_blocked_by_tags() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.RootDeviceId).RowVersion); + + var result = await service.DeleteDeviceAsync(RawTreeTestDb.RootDeviceId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("tags"); + } + + [Fact] + public async Task DeleteTagGroup_blocked_by_tags() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.TagGroups.Single(g => g.TagGroupId == RawTreeTestDb.RootGroupId).RowVersion); + + var result = await service.DeleteTagGroupAsync(RawTreeTestDb.RootGroupId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("tags"); + } + + [Fact] + public async Task DeleteDriver_blocked_when_a_device_holds_tags_names_the_device() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId).RowVersion); + + var result = await service.DeleteDriverAsync(RawTreeTestDb.RootDriverId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain("Device1"); // the offending device is named + } + + [Fact] + public async Task DeleteTag_blocked_by_reference_names_the_equipment() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.ReferencedTagId).RowVersion); + + var result = await service.DeleteTagAsync(RawTreeTestDb.ReferencedTagId, rv); + + result.Ok.ShouldBeFalse(); + result.Error!.ShouldContain(RawTreeTestDb.EquipmentName); + result.Error!.ShouldContain(RawTreeTestDb.EquipmentId); + } + + // -------------------------------------------------------------------------------- Delete happy + + [Fact] + public async Task DeleteTag_succeeds_when_unreferenced() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion); + + var result = await service.DeleteTagAsync(RawTreeTestDb.PlainTagId, rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.Tags.Any(t => t.TagId == RawTreeTestDb.PlainTagId).ShouldBeFalse(); + } + + [Fact] + public async Task DeleteFolder_succeeds_when_empty() + { + var (service, dbName) = Seeded(); + var created = await service.CreateFolderAsync(RawTreeTestDb.ClusterId, null, "Empty"); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == created.CreatedId!).RowVersion); + + var result = await service.DeleteFolderAsync(created.CreatedId!, rv); + + result.Ok.ShouldBeTrue(); + } + + // ------------------------------------------------------------------------------------ Move + + [Fact] + public async Task MoveDriverToFolder_reparents_the_driver() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId).RowVersion); + + var result = await service.MoveDriverToFolderAsync(RawTreeTestDb.RootDriverId, RawTreeTestDb.RootFolderId, rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.RootDriverId) + .RawFolderId.ShouldBe(RawTreeTestDb.RootFolderId); + } + + [Fact] + public async Task MoveDriverToFolder_to_cluster_root_clears_the_folder() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion); + + var result = await service.MoveDriverToFolderAsync(RawTreeTestDb.FolderedDriverId, null, rv); + + result.Ok.ShouldBeTrue(); + using var db = RawTreeTestDb.CreateNamed(dbName); + db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId) + .RawFolderId.ShouldBeNull(); + } + + // -------------------------------------------------------------------------- Concurrency / edit + + [Fact] + public async Task RenameFolder_surfaces_failure_when_row_vanished_concurrently() + { + var (service, dbName) = Seeded(); + var rv = RowVersionOf(dbName, db => db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId).RowVersion); + + // Simulate another actor deleting the folder's contents + the folder itself out from under us. + using (var db = RawTreeTestDb.CreateNamed(dbName)) + { + db.RawFolders.Remove(db.RawFolders.Single(f => f.RawFolderId == RawTreeTestDb.RootFolderId)); + db.SaveChanges(); + } + + var result = await service.RenameFolderAsync(RawTreeTestDb.RootFolderId, "Whatever", rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Warnings.ShouldBeEmpty(); + } + + [Fact] + public async Task LoadTagForEdit_returns_projection() + { + var (service, _) = Seeded(); + + var dto = await service.LoadTagForEditAsync(RawTreeTestDb.HistorizedTagId); + + dto.ShouldNotBeNull(); + dto.Value.Name.ShouldBe("speed"); + dto.Value.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId); + dto.Value.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId); + dto.Value.DataType.ShouldBe("Float"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeTestDb.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeTestDb.cs new file mode 100644 index 00000000..bc3c93b3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTreeTestDb.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Shared in-memory fixture for RawTreeService tests. Seeds one enterprise ("zb") with a single +/// cluster ("MAIN") carrying a small but complete Raw-tree slice: +/// a root folder → driver → default device → a nested tag-group with two tags (one historized), plus a +/// cluster-root driver whose device has a device-root tag referenced by a UNS equipment. +/// +internal static class RawTreeTestDb +{ + public const string ClusterId = "MAIN"; + public const string RootFolderId = "RF-root"; + public const string FolderedDriverId = "DRV-foldered"; + public const string FolderedDeviceId = "DEV-foldered"; + public const string RootGroupId = "TG-root"; + public const string HistorizedTagId = "TAG-hist"; + public const string PlainTagId = "TAG-plain"; + + public const string RootDriverId = "DRV-root"; + public const string RootDeviceId = "DEV-root"; + public const string ReferencedTagId = "TAG-referenced"; + + public const string EquipmentId = "EQ-000000000001"; + public const string EquipmentName = "packer-1"; + + /// Creates a context bound to the supplied InMemory database name. + public static OtOpcUaConfigDbContext CreateNamed(string name) => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(name) + .Options); + + /// An whose contexts share one InMemory database. + public static IDbContextFactory Factory(string name) => new NamedFactory(name); + + /// Seeds the fixture into a fresh, uniquely-named InMemory database and returns its name. + public static string SeedFresh() + { + var name = $"raw-{Guid.NewGuid():N}"; + using var db = CreateNamed(name); + Seed(db); + return name; + } + + /// Seeds the fixture into the supplied (already-bound) context and saves. + public static void Seed(OtOpcUaConfigDbContext db) + { + db.ServerClusters.Add(new ServerCluster + { + ClusterId = ClusterId, + Name = "Main", + Enterprise = "zb", + Site = "warsaw-west", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }); + + // Foldered branch: folder → driver → device → group → two tags (one historized). + db.RawFolders.Add(new RawFolder + { + RawFolderId = RootFolderId, + ClusterId = ClusterId, + ParentRawFolderId = null, + Name = "PLCs", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = FolderedDriverId, + ClusterId = ClusterId, + RawFolderId = RootFolderId, + Name = "modbus-1", + DriverType = "Modbus", + DriverConfig = "{}", + }); + db.Devices.Add(new Device + { + DeviceId = FolderedDeviceId, + DriverInstanceId = FolderedDriverId, + Name = "Device1", + DeviceConfig = "{}", + }); + db.TagGroups.Add(new TagGroup + { + TagGroupId = RootGroupId, + DeviceId = FolderedDeviceId, + ParentTagGroupId = null, + Name = "Motors", + }); + db.Tags.Add(new Tag + { + TagId = HistorizedTagId, + DeviceId = FolderedDeviceId, + TagGroupId = RootGroupId, + Name = "speed", + DataType = "Float", + AccessLevel = TagAccessLevel.ReadWrite, + TagConfig = "{\"isHistorized\":true}", + }); + db.Tags.Add(new Tag + { + TagId = PlainTagId, + DeviceId = FolderedDeviceId, + TagGroupId = RootGroupId, + Name = "state", + DataType = "Boolean", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + + // Cluster-root branch: driver → device → device-root tag referenced by a UNS equipment. + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = RootDriverId, + ClusterId = ClusterId, + RawFolderId = null, + Name = "s7-1", + DriverType = "S7", + DriverConfig = "{}", + }); + db.Devices.Add(new Device + { + DeviceId = RootDeviceId, + DriverInstanceId = RootDriverId, + Name = "Device1", + DeviceConfig = "{}", + }); + db.Tags.Add(new Tag + { + TagId = ReferencedTagId, + DeviceId = RootDeviceId, + TagGroupId = null, + Name = "temperature", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + + // A UNS equipment referencing the cluster-root tag (delete-block + rename-warning source). + db.Equipment.Add(new Equipment + { + EquipmentId = EquipmentId, + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-1", + Name = EquipmentName, + MachineCode = "packer_001", + }); + db.UnsTagReferences.Add(new UnsTagReference + { + UnsTagReferenceId = "REF-1", + EquipmentId = EquipmentId, + TagId = ReferencedTagId, + }); + + db.SaveChanges(); + } + + private sealed class NamedFactory(string name) : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => + Task.FromResult(CreateNamed(name)); + } +}