feat(uns): area + line CRUD with #122 reassignment guard

This commit is contained in:
Joseph Doherty
2026-06-08 12:35:58 -04:00
parent c9f59e4bd2
commit 47b1d2259f
4 changed files with 628 additions and 0 deletions
@@ -26,4 +26,78 @@ public interface IUnsTreeService
/// <param name="ct">A token to cancel the load.</param>
/// <returns>Tag nodes followed by VirtualTag nodes; empty if the equipment has none.</returns>
Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
/// Whitespace-only notes are stored as <c>null</c>.
/// </summary>
/// <param name="clusterId">The owning cluster.</param>
/// <param name="unsAreaId">The unique area id to create.</param>
/// <param name="name">The area name.</param>
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default);
/// <summary>
/// Updates a UNS area's name, notes, and owning cluster. When the cluster changes, the
/// decision-#122 reassignment guard blocks the move if any driver-bound equipment under the
/// area is bound to a driver in a different cluster than the target. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsArea.RowVersion"/>.
/// </summary>
/// <param name="unsAreaId">The area to update.</param>
/// <param name="name">The new name.</param>
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
/// <param name="newClusterId">The target cluster (may equal the current one).</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a UNS area. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because lines still reference the area surfaces a
/// guidance message.
/// </summary>
/// <param name="unsAreaId">The area to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new UNS line under an area. Fails if a line with the same id already exists.
/// Whitespace-only notes are stored as <c>null</c>.
/// </summary>
/// <param name="unsAreaId">The owning area.</param>
/// <param name="unsLineId">The unique line id to create.</param>
/// <param name="name">The line name.</param>
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default);
/// <summary>
/// Updates a UNS line's owning area, name, and notes. Uses last-write-wins optimistic
/// concurrency on <see cref="Configuration.Entities.UnsLine.RowVersion"/>.
/// </summary>
/// <param name="unsLineId">The line to update.</param>
/// <param name="name">The new name.</param>
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
/// <param name="newUnsAreaId">The target parent area.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a UNS line. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because equipment still references the line
/// surfaces a guidance message.
/// </summary>
/// <param name="unsLineId">The line to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Outcome of a UNS structural mutation (create/update/delete of an area or line).
/// On success <see cref="Ok"/> is <c>true</c> and <see cref="Error"/> is <c>null</c>;
/// on a guarded or concurrency failure <see cref="Ok"/> is <c>false</c> and
/// <see cref="Error"/> carries the operator-facing message the caller should surface.
/// </summary>
/// <param name="Ok">Whether the mutation was applied.</param>
/// <param name="Error">The operator-facing failure message, or <c>null</c> on success.</param>
public readonly record struct UnsMutationResult(bool Ok, string? Error);
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -121,4 +122,218 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
result.AddRange(vtagNodes);
return result;
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateAreaAsync(
string clusterId,
string unsAreaId,
string name,
string? notes,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == unsAreaId, ct))
{
return new UnsMutationResult(false, $"Area '{unsAreaId}' already exists.");
}
db.UnsAreas.Add(new UnsArea
{
UnsAreaId = unsAreaId,
ClusterId = clusterId,
Name = name,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateAreaAsync(
string unsAreaId,
string name,
string? notes,
string newClusterId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Decision #122: a cluster move must not orphan driver-bound equipment from its driver's
// cluster. Any equipment under this area that is bound to a driver in a different cluster
// than the target blocks the move.
if (newClusterId != entity.ClusterId)
{
var lineIds = await db.UnsLines
.Where(l => l.UnsAreaId == unsAreaId)
.Select(l => l.UnsLineId)
.ToListAsync(ct);
var boundEquipment = await db.Equipment
.Where(eq => lineIds.Contains(eq.UnsLineId) && eq.DriverInstanceId != null)
.Select(eq => new { eq.EquipmentId, eq.DriverInstanceId })
.ToListAsync(ct);
foreach (var eq in boundEquipment)
{
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == eq.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is not null && driverCluster != newClusterId)
{
return new UnsMutationResult(
false,
$"Cannot move area to '{newClusterId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
}
}
}
entity.Name = name;
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
entity.ClusterId = newClusterId;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this area while you were editing. Reload to see the latest values.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteAreaAsync(
string unsAreaId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsAreas.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this area while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateLineAsync(
string unsAreaId,
string unsLineId,
string name,
string? notes,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.UnsLines.AnyAsync(l => l.UnsLineId == unsLineId, ct))
{
return new UnsMutationResult(false, $"Line '{unsLineId}' already exists.");
}
db.UnsLines.Add(new UnsLine
{
UnsLineId = unsLineId,
UnsAreaId = unsAreaId,
Name = name,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateLineAsync(
string unsLineId,
string name,
string? notes,
string newUnsAreaId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.UnsAreaId = newUnsAreaId;
entity.Name = name;
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this line while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteLineAsync(
string unsLineId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsLines.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this line while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first.");
}
}
}
@@ -0,0 +1,328 @@
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 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();
}
}