using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Verifies the Area/Line CRUD mutations on , including the /// decision-#122 area-reassignment guard that blocks moving an area to a different cluster /// when its driver-bound equipment would be orphaned from its driver's cluster. /// /// /// The EF InMemory provider does not enforce RowVersion concurrency, so the /// DbUpdateConcurrencyException branches are not exercised here by design. /// [Trait("Category", "Unit")] public sealed class UnsTreeServiceAreaLineTests { private static (UnsTreeService Service, string DbName) Fresh() { var dbName = $"uns-crud-{Guid.NewGuid():N}"; return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); } // ----- CreateArea ----- /// A new area is persisted and visible on a fresh context. [Fact] public async Task CreateArea_then_load_shows_it() { var (service, dbName) = Fresh(); var result = await service.CreateAreaAsync("MAIN", "AREA-NEW", "assembly", " "); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var db = UnsTreeTestDb.CreateNamed(dbName); var area = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-NEW"); area.ClusterId.ShouldBe("MAIN"); area.Name.ShouldBe("assembly"); area.Notes.ShouldBeNull(); // whitespace-only notes collapse to null } /// Creating an area whose id already exists returns the duplicate error. [Fact] public async Task CreateArea_duplicate_id_returns_error() { var (service, dbName) = Fresh(); await service.CreateAreaAsync("MAIN", "AREA-DUP", "first", null); var result = await service.CreateAreaAsync("MAIN", "AREA-DUP", "second", null); result.Ok.ShouldBeFalse(); result.Error.ShouldBe("Area 'AREA-DUP' already exists."); using var db = UnsTreeTestDb.CreateNamed(dbName); db.UnsAreas.Single(a => a.UnsAreaId == "AREA-DUP").Name.ShouldBe("first"); } // ----- UpdateArea ----- /// Updating an area changes its name and notes (notes whitespace collapses to null). [Fact] public async Task UpdateArea_changes_name_and_notes() { var (service, dbName) = Fresh(); await service.CreateAreaAsync("MAIN", "AREA-1", "old", "old notes"); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-1").RowVersion; } var result = await service.UpdateAreaAsync("AREA-1", "new", " ", "MAIN", rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var verify = UnsTreeTestDb.CreateNamed(dbName); var area = verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-1"); area.Name.ShouldBe("new"); area.Notes.ShouldBeNull(); area.ClusterId.ShouldBe("MAIN"); } /// The #122 guard blocks moving an area to a new cluster when driver-bound /// equipment would be orphaned from its driver's cluster. [Fact] public async Task UpdateArea_reassign_cluster_blocked_when_driver_bound_equipment_would_orphan() { var (service, dbName) = Fresh(); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-X", ClusterId = "MAIN", Name = "a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-X", UnsAreaId = "AREA-X", Name = "l" }); db.Equipment.Add(new Equipment { EquipmentId = "EQ-BOUND", EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-X", Name = "m", MachineCode = "machine_x", DriverInstanceId = "DRV-MAIN", }); db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-MAIN", ClusterId = "MAIN", NamespaceId = "NS-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}", }); db.SaveChanges(); rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").RowVersion; } var result = await service.UpdateAreaAsync("AREA-X", "a", null, "SITE-A", rv); result.Ok.ShouldBeFalse(); result.Error.ShouldNotBeNull(); result.Error.ShouldContain("decision #122"); result.Error.ShouldContain("EQ-BOUND"); result.Error.ShouldContain("SITE-A"); result.Error.ShouldContain("MAIN"); // The area must not have been moved. using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").ClusterId.ShouldBe("MAIN"); } /// The #122 guard allows the move when the area's driver-bound equipment's driver /// is already in the target cluster (driverCluster == newClusterId → no orphan). [Fact] public async Task UpdateArea_reassign_cluster_allowed_when_driver_is_in_target_cluster() { // Seed: AREA-Z in cluster MAIN, a line, equipment bound to DRV-SITE-A whose cluster is // SITE-A. Reassigning the area to SITE-A must be allowed because the driver is already // there — the #122 guard's `driverCluster != newClusterId` condition is false. var (service, dbName) = Fresh(); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-Z", ClusterId = "MAIN", Name = "a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-Z", UnsAreaId = "AREA-Z", Name = "l" }); db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-SITE-A", ClusterId = "SITE-A", NamespaceId = "NS-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}", }); db.Equipment.Add(new Equipment { EquipmentId = "EQ-BOUND-Z", EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-Z", Name = "m", MachineCode = "machine_z", DriverInstanceId = "DRV-SITE-A", }); db.SaveChanges(); rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-Z").RowVersion; } var result = await service.UpdateAreaAsync("AREA-Z", "a", null, "SITE-A", rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); // Verify the area actually moved to SITE-A via a fresh context. using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-Z").ClusterId.ShouldBe("SITE-A"); } /// The #122 guard allows the move when the equipment under the area is driver-less /// (DriverInstanceId == null). [Fact] public async Task UpdateArea_reassign_cluster_allowed_when_equipment_driverless() { var (service, dbName) = Fresh(); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-Y", ClusterId = "MAIN", Name = "a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-Y", UnsAreaId = "AREA-Y", Name = "l" }); db.Equipment.Add(new Equipment { EquipmentId = "EQ-FREE", EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-Y", Name = "m", MachineCode = "machine_y", DriverInstanceId = null, }); db.SaveChanges(); rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").RowVersion; } var result = await service.UpdateAreaAsync("AREA-Y", "a", null, "SITE-A", rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").ClusterId.ShouldBe("SITE-A"); } /// Updating an area that no longer exists returns the row-gone error. [Fact] public async Task UpdateArea_missing_row_returns_error() { var (service, _) = Fresh(); var result = await service.UpdateAreaAsync("NOPE", "n", null, "MAIN", []); result.Ok.ShouldBeFalse(); result.Error.ShouldBe("Row no longer exists."); } // ----- DeleteArea ----- /// Deleting an area with no lines removes the row. [Fact] public async Task DeleteArea_removes_row() { var (service, dbName) = Fresh(); await service.CreateAreaAsync("MAIN", "AREA-DEL", "a", null); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-DEL").RowVersion; } var result = await service.DeleteAreaAsync("AREA-DEL", rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.UnsAreas.Any(a => a.UnsAreaId == "AREA-DEL").ShouldBeFalse(); } /// Deleting an area that is already gone is a no-op success. [Fact] public async Task DeleteArea_already_gone_returns_ok() { var (service, _) = Fresh(); var result = await service.DeleteAreaAsync("GHOST", []); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); } // ----- CreateLine ----- /// A new line is persisted under its area. [Fact] public async Task CreateLine_then_load_shows_it() { var (service, dbName) = Fresh(); await service.CreateAreaAsync("MAIN", "AREA-1", "a", null); var result = await service.CreateLineAsync("AREA-1", "LINE-NEW", "line-a", " "); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var db = UnsTreeTestDb.CreateNamed(dbName); var line = db.UnsLines.Single(l => l.UnsLineId == "LINE-NEW"); line.UnsAreaId.ShouldBe("AREA-1"); line.Name.ShouldBe("line-a"); line.Notes.ShouldBeNull(); } /// Creating a line whose id already exists returns the duplicate error. [Fact] public async Task CreateLine_duplicate_id_returns_error() { var (service, _) = Fresh(); await service.CreateLineAsync("AREA-1", "LINE-DUP", "first", null); var result = await service.CreateLineAsync("AREA-1", "LINE-DUP", "second", null); result.Ok.ShouldBeFalse(); result.Error.ShouldBe("Line 'LINE-DUP' already exists."); } // ----- UpdateLine ----- /// Updating a line moves it to a new area and changes its name and notes. [Fact] public async Task UpdateLine_changes_area_name_and_notes() { var (service, dbName) = Fresh(); await service.CreateLineAsync("AREA-1", "LINE-1", "old", "old notes"); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-1").RowVersion; } var result = await service.UpdateLineAsync("LINE-1", "new", " ", "AREA-2", rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var verify = UnsTreeTestDb.CreateNamed(dbName); var line = verify.UnsLines.Single(l => l.UnsLineId == "LINE-1"); line.UnsAreaId.ShouldBe("AREA-2"); line.Name.ShouldBe("new"); line.Notes.ShouldBeNull(); } /// Updating a line that no longer exists returns the row-gone error. [Fact] public async Task UpdateLine_missing_row_returns_error() { var (service, _) = Fresh(); var result = await service.UpdateLineAsync("NOPE", "n", null, "AREA-1", []); result.Ok.ShouldBeFalse(); result.Error.ShouldBe("Row no longer exists."); } /// The #122 guard blocks reparenting a line to an area in a different cluster when /// the line's equipment is driver-bound (the driver lives in the original cluster). [Fact] public async Task UpdateLine_reparent_to_other_cluster_blocked_when_driver_bound() { var (service, dbName) = Fresh(); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { // Source area A1 in cluster MAIN. db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-MAIN", ClusterId = "MAIN", Name = "area-main" }); // Target area A2 in cluster SITE-A. db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-SITE-A", ClusterId = "SITE-A", Name = "area-site-a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-BOUND", UnsAreaId = "A1-MAIN", Name = "line" }); db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-MAIN-122", ClusterId = "MAIN", NamespaceId = "NS-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}", }); db.Equipment.Add(new Equipment { EquipmentId = "EQ-LINE-BOUND", EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-BOUND", Name = "eq", MachineCode = "mc_line_bound", DriverInstanceId = "DRV-MAIN-122", }); db.SaveChanges(); rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").RowVersion; } var result = await service.UpdateLineAsync("LINE-BOUND", "line", null, "A2-SITE-A", rv); result.Ok.ShouldBeFalse(); result.Error.ShouldNotBeNull(); result.Error.ShouldContain("decision #122"); result.Error.ShouldContain("EQ-LINE-BOUND"); result.Error.ShouldContain("A2-SITE-A"); result.Error.ShouldContain("MAIN"); // The line must not have moved. using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.UnsLines.Single(l => l.UnsLineId == "LINE-BOUND").UnsAreaId.ShouldBe("A1-MAIN"); } /// The #122 guard allows reparenting a line to an area in a different cluster when /// the line's equipment is driver-less (DriverInstanceId == null). [Fact] public async Task UpdateLine_reparent_to_other_cluster_allowed_when_equipment_driverless() { var (service, dbName) = Fresh(); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { db.UnsAreas.Add(new UnsArea { UnsAreaId = "A1-FREE", ClusterId = "MAIN", Name = "area-main" }); db.UnsAreas.Add(new UnsArea { UnsAreaId = "A2-FREE", ClusterId = "SITE-A", Name = "area-site-a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-FREE", UnsAreaId = "A1-FREE", Name = "line" }); db.Equipment.Add(new Equipment { EquipmentId = "EQ-LINE-FREE", EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-FREE", Name = "eq", MachineCode = "mc_line_free", DriverInstanceId = null, }); db.SaveChanges(); rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").RowVersion; } var result = await service.UpdateLineAsync("LINE-FREE", "line", null, "A2-FREE", rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); // The line's UnsAreaId must have changed to A2-FREE. using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.UnsLines.Single(l => l.UnsLineId == "LINE-FREE").UnsAreaId.ShouldBe("A2-FREE"); } // ----- DeleteLine ----- /// Deleting a line removes the row. [Fact] public async Task DeleteLine_removes_row() { var (service, dbName) = Fresh(); await service.CreateLineAsync("AREA-1", "LINE-DEL", "l", null); byte[] rv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-DEL").RowVersion; } var result = await service.DeleteLineAsync("LINE-DEL", rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var verify = UnsTreeTestDb.CreateNamed(dbName); verify.UnsLines.Any(l => l.UnsLineId == "LINE-DEL").ShouldBeFalse(); } /// Deleting a line that is already gone is a no-op success. [Fact] public async Task DeleteLine_already_gone_returns_ok() { var (service, _) = Fresh(); var result = await service.DeleteLineAsync("GHOST", []); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); } }