376 lines
13 KiB
C#
376 lines
13 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Verifies the Area/Line CRUD mutations on <see cref="UnsTreeService"/>, 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
|
|
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
|
|
/// </remarks>
|
|
[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 -----
|
|
|
|
/// <summary>A new area is persisted and visible on a fresh context.</summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Creating an area whose id already exists returns the duplicate error.</summary>
|
|
[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 -----
|
|
|
|
/// <summary>Updating an area changes its name and notes (notes whitespace collapses to null).</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>The #122 guard blocks moving an area to a new cluster when driver-bound
|
|
/// equipment would be orphaned from its driver's cluster.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>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).</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>The #122 guard allows the move when the equipment under the area is driver-less
|
|
/// (DriverInstanceId == null).</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Updating an area that no longer exists returns the row-gone error.</summary>
|
|
[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 -----
|
|
|
|
/// <summary>Deleting an area with no lines removes the row.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Deleting an area that is already gone is a no-op success.</summary>
|
|
[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 -----
|
|
|
|
/// <summary>A new line is persisted under its area.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Creating a line whose id already exists returns the duplicate error.</summary>
|
|
[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 -----
|
|
|
|
/// <summary>Updating a line moves it to a new area and changes its name and notes.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Updating a line that no longer exists returns the row-gone error.</summary>
|
|
[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.");
|
|
}
|
|
|
|
// ----- DeleteLine -----
|
|
|
|
/// <summary>Deleting a line removes the row.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Deleting a line that is already gone is a no-op success.</summary>
|
|
[Fact]
|
|
public async Task DeleteLine_already_gone_returns_ok()
|
|
{
|
|
var (service, _) = Fresh();
|
|
|
|
var result = await service.DeleteLineAsync("GHOST", []);
|
|
|
|
result.Ok.ShouldBeTrue();
|
|
result.Error.ShouldBeNull();
|
|
}
|
|
}
|