feat(adminui): B2-WP1 RawTreeService + lazy tree data layer + mutations
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -48,6 +48,7 @@ public static class EndpointRouteBuilderExtensions
|
||||
services.AddHostedService<Browsing.BrowseSessionReaper>();
|
||||
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
|
||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||
services.AddScoped<IRawTreeService, RawTreeService>();
|
||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Detached, editable projection of a raw <c>Tag</c> — 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).
|
||||
/// </summary>
|
||||
/// <param name="TagId">The tag's stable logical id.</param>
|
||||
/// <param name="DeviceId">The owning device's logical id.</param>
|
||||
/// <param name="TagGroupId">The containing tag group's logical id, or null when directly under the device.</param>
|
||||
/// <param name="Name">The tag name (a RawPath segment).</param>
|
||||
/// <param name="DataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="AccessLevel">The tag's access-level baseline.</param>
|
||||
/// <param name="WriteIdempotent">Whether writes to this tag are retry-eligible.</param>
|
||||
/// <param name="PollGroupId">The optional poll-group id.</param>
|
||||
/// <param name="TagConfig">The schemaless per-driver TagConfig JSON blob.</param>
|
||||
/// <param name="RowVersion">The optimistic-concurrency token.</param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Loads and mutates the v3 Raw project tree (<c>/raw</c>) — the cluster-rooted
|
||||
/// Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy — from the config
|
||||
@@ -41,4 +70,185 @@ public interface IRawTreeService
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The parent's direct child nodes; empty for a leaf or an empty container.</returns>
|
||||
Task<IReadOnlyList<RawNode>> LoadChildrenAsync(RawNode parent, CancellationToken ct = default);
|
||||
|
||||
// --- Folder mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <c>RawFolder</c> under a cluster (<paramref name="parentFolderId"/> 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 <c>CreatedId</c> carries the new folder's
|
||||
/// logical id.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The owning cluster id.</param>
|
||||
/// <param name="parentFolderId">The parent folder's logical id, or null for a cluster-root folder.</param>
|
||||
/// <param name="name">The folder name (a RawPath segment).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>RawFolderId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateFolderAsync(string clusterId, string? parentFolderId, string name, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renames a <c>RawFolder</c>. 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
|
||||
/// <see cref="RawRenameResult"/> carries advisory warnings for historized and equipment-referenced
|
||||
/// tags in the folder's subtree.
|
||||
/// </summary>
|
||||
/// <param name="rawFolderId">The folder's logical id.</param>
|
||||
/// <param name="newName">The new folder name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameFolderAsync(string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an empty <c>RawFolder</c>. Blocked (with the blocker named) while it still contains child
|
||||
/// folders or driver instances.
|
||||
/// </summary>
|
||||
/// <param name="rawFolderId">The folder's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteFolderAsync(string rawFolderId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a driver instance into a folder (<paramref name="targetFolderId"/>) 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.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="targetFolderId">The destination folder's logical id, or null for the cluster root.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The move outcome.</returns>
|
||||
Task<UnsMutationResult> MoveDriverToFolderAsync(string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- Driver mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal driver instance and its auto-created default <c>Device</c> 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 (<c>{}</c>) default device. The driver name is a
|
||||
/// RawPath segment, unique among its cluster/folder siblings.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The owning cluster id.</param>
|
||||
/// <param name="folderId">The containing folder's logical id, or null for a cluster-root driver.</param>
|
||||
/// <param name="name">The driver instance name (a RawPath segment).</param>
|
||||
/// <param name="driverType">The driver-type string (see <c>DriverTypeNames</c>).</param>
|
||||
/// <param name="driverConfigJson">The driver config JSON blob (opaque here; validated in Wave B).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DriverInstanceId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateDriverAsync(string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="newName">The new driver name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameDriverAsync(string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Enables or disables a driver instance (last-write-wins on <paramref name="rowVersion"/>).</summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="enabled">The new enabled state.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The update outcome.</returns>
|
||||
Task<UnsMutationResult> SetDriverEnabledAsync(string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The driver instance's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteDriverAsync(string driverInstanceId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- Device mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal <c>Device</c> 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.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The owning driver instance's logical id.</param>
|
||||
/// <param name="name">The device name (a RawPath segment).</param>
|
||||
/// <param name="deviceConfigJson">The device config JSON blob (opaque here; validated in Wave B).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DeviceId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateDeviceAsync(string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renames a <c>Device</c>. Validates the new name as a driver-unique RawPath segment and returns
|
||||
/// downstream-impact warnings for historized/equipment-referenced tags beneath the device.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="newName">The new device name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameDeviceAsync(string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Deletes a <c>Device</c>. Blocked (with the blocker named) while it holds tag groups or tags.</summary>
|
||||
/// <param name="deviceId">The device's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteDeviceAsync(string deviceId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- TagGroup mutations ---
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <c>TagGroup</c> under a device (<paramref name="parentGroupId"/> null) or under
|
||||
/// another group. The name is validated as a RawPath segment, unique among its siblings.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The owning device's logical id.</param>
|
||||
/// <param name="parentGroupId">The parent group's logical id, or null for a device-root group.</param>
|
||||
/// <param name="name">The group name (a RawPath segment).</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>TagGroupId</c> on success.</returns>
|
||||
Task<UnsMutationResult> CreateTagGroupAsync(string deviceId, string? parentGroupId, string name, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renames a <c>TagGroup</c>. Validates the new name as a sibling-unique RawPath segment and returns
|
||||
/// downstream-impact warnings for historized/equipment-referenced tags beneath the group.
|
||||
/// </summary>
|
||||
/// <param name="tagGroupId">The group's logical id.</param>
|
||||
/// <param name="newName">The new group name.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
|
||||
Task<RawRenameResult> RenameTagGroupAsync(string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Deletes a <c>TagGroup</c>. Blocked (with the blocker named) while it holds child groups or tags.</summary>
|
||||
/// <param name="tagGroupId">The group's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteTagGroupAsync(string tagGroupId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
// --- Tag mutations / projections (create + update are WP4) ---
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a raw <c>Tag</c>. Blocked while any <c>UnsTagReference</c> points at it; the failure
|
||||
/// message names the referencing equipment(s) so the operator can remove the reference(s) first.
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag's logical id.</param>
|
||||
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The delete outcome.</returns>
|
||||
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="RawTagEditDto"/>).
|
||||
/// </summary>
|
||||
/// <param name="tagId">The tag's logical id.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The tag's editable projection, or null when the tag does not exist.</returns>
|
||||
Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, EF-free assembly of the eager top of the Raw project tree (<c>/raw</c>) — Enterprise → Cluster —
|
||||
/// from flat cluster rows. The peer of <see cref="UnsTreeAssembly"/> for the Raw subtree. Everything below
|
||||
/// a cluster loads lazily via <see cref="IRawTreeService.LoadChildrenAsync"/>, 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.
|
||||
/// </summary>
|
||||
public static class RawTreeAssembly
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the Enterprise→Cluster roots. Ordering is deterministic and ordinal at both levels. Each
|
||||
/// cluster is marked <see cref="RawNode.HasLazyChildren"/> and carries the supplied root child count
|
||||
/// (its root folders + cluster-root drivers).
|
||||
/// </summary>
|
||||
/// <param name="clusters">The flat cluster rows to nest under their enterprise label.</param>
|
||||
/// <param name="rootChildCounts">Per-cluster count of root folders + cluster-root drivers (badge).</param>
|
||||
/// <returns>The assembled enterprise root nodes, populated down to (but not through) their clusters.</returns>
|
||||
public static IReadOnlyList<RawNode> BuildRoots(
|
||||
IReadOnlyList<ClusterRow> clusters,
|
||||
IReadOnlyDictionary<string, int> 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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="RawTreeService"/> — 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.
|
||||
/// <para>
|
||||
/// As with the <c>UnsTreeService</c> suite, the EF InMemory provider does not enforce <c>RowVersion</c>
|
||||
/// concurrency tokens, so the <c>DbUpdateConcurrencyException</c> 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext, byte[]> 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Shared in-memory fixture for <c>RawTreeService</c> 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.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>Creates a context bound to the supplied InMemory database name.</summary>
|
||||
public static OtOpcUaConfigDbContext CreateNamed(string name) =>
|
||||
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(name)
|
||||
.Options);
|
||||
|
||||
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose contexts share one InMemory database.</summary>
|
||||
public static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => new NamedFactory(name);
|
||||
|
||||
/// <summary>Seeds the fixture into a fresh, uniquely-named InMemory database and returns its name.</summary>
|
||||
public static string SeedFresh()
|
||||
{
|
||||
var name = $"raw-{Guid.NewGuid():N}";
|
||||
using var db = CreateNamed(name);
|
||||
Seed(db);
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>Seeds the fixture into the supplied (already-bound) context and saves.</summary>
|
||||
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<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name);
|
||||
|
||||
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(CreateNamed(name));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user