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"); } }