diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
index fdf1c147..b3ba077b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
@@ -26,4 +26,78 @@ public interface IUnsTreeService
/// A token to cancel the load.
/// Tag nodes followed by VirtualTag nodes; empty if the equipment has none.
Task> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default);
+
+ ///
+ /// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
+ /// Whitespace-only notes are stored as null.
+ ///
+ /// The owning cluster.
+ /// The unique area id to create.
+ /// The area name.
+ /// Optional notes; whitespace collapses to null.
+ /// A token to cancel the operation.
+ /// Success, or a duplicate-id failure.
+ Task CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default);
+
+ ///
+ /// 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 .
+ ///
+ /// The area to update.
+ /// The new name.
+ /// The new notes; whitespace collapses to null.
+ /// The target cluster (may equal the current one).
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a missing-row failure, a #122 guard failure, or a concurrency failure.
+ Task UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// 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.
+ ///
+ /// The area to delete.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a concurrency failure, or a delete-failed failure.
+ Task DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// Creates a new UNS line under an area. Fails if a line with the same id already exists.
+ /// Whitespace-only notes are stored as null.
+ ///
+ /// The owning area.
+ /// The unique line id to create.
+ /// The line name.
+ /// Optional notes; whitespace collapses to null.
+ /// A token to cancel the operation.
+ /// Success, or a duplicate-id failure.
+ Task CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default);
+
+ ///
+ /// Updates a UNS line's owning area, name, and notes. Uses last-write-wins optimistic
+ /// concurrency on .
+ ///
+ /// The line to update.
+ /// The new name.
+ /// The new notes; whitespace collapses to null.
+ /// The target parent area.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a missing-row failure, or a concurrency failure.
+ Task UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// 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.
+ ///
+ /// The line to delete.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a concurrency failure, or a delete-failed failure.
+ Task DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs
new file mode 100644
index 00000000..ff5be32b
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs
@@ -0,0 +1,11 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+///
+/// Outcome of a UNS structural mutation (create/update/delete of an area or line).
+/// On success is true and is null;
+/// on a guarded or concurrency failure is false and
+/// carries the operator-facing message the caller should surface.
+///
+/// Whether the mutation was applied.
+/// The operator-facing failure message, or null on success.
+public readonly record struct UnsMutationResult(bool Ok, string? Error);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
index 7096688b..ce1bfc82 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
@@ -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 dbF
result.AddRange(vtagNodes);
return result;
}
+
+ ///
+ public async Task 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);
+ }
+
+ ///
+ public async Task 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.");
+ }
+ }
+
+ ///
+ public async Task 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.");
+ }
+ }
+
+ ///
+ public async Task 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);
+ }
+
+ ///
+ public async Task 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.");
+ }
+ }
+
+ ///
+ public async Task 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.");
+ }
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs
new file mode 100644
index 00000000..e08036a0
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs
@@ -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;
+
+///
+/// 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 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.");
+ }
+
+ // ----- 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();
+ }
+}