feat(adminui): B2-WP1 RawTreeService + lazy tree data layer + mutations

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:34:36 -04:00
parent fbf3c26c2a
commit 76b8325b84
6 changed files with 1807 additions and 0 deletions
@@ -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));
}
}