From 5ee510dc1a97aead94003c99551bd25f059ac316 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 22:30:48 -0400 Subject: [PATCH] =?UTF-8?q?UnsTab=20native=20HTML5=20drag/drop=20+=20409?= =?UTF-8?q?=20concurrent-edit=20modal=20+=20optimistic-concurrency=20commi?= =?UTF-8?q?t=20path.=20Closes=20UI=20slice=20of=20task=20#153=20(Phase=206?= =?UTF-8?q?.4=20Stream=20A=20UI=20follow-up).=20Playwright=20E2E=20smoke?= =?UTF-8?q?=20is=20split=20into=20new=20task=20#199=20=E2=80=94=20Playwrig?= =?UTF-8?q?ht=20install=20+=20WebApplicationFactory=20+=20seeded-DB=20harn?= =?UTF-8?q?ess=20is=20genuinely=20its=20own=20infra-setup=20PR.=20Native?= =?UTF-8?q?=20HTML5=20attributes=20(draggable,=20@ondragstart,=20@ondragov?= =?UTF-8?q?er,=20@ondragleave,=20@ondrop)=20deliberately=20over=20MudBlazo?= =?UTF-8?q?r=20per=20the=20task=20title=20=E2=80=94=20no=20MudBlazor=20eve?= =?UTF-8?q?r=20joins=20this=20project.=20Two=20new=20service=20methods=20o?= =?UTF-8?q?n=20UnsService=20land=20the=20data=20layer=20the=20existing=20U?= =?UTF-8?q?nsImpactAnalyzer=20assumed=20but=20which=20didn't=20actually=20?= =?UTF-8?q?exist:=20(1)=20LoadSnapshotAsync(generationId)=20=E2=80=94=20wa?= =?UTF-8?q?lks=20UnsAreas=20+=20UnsLines=20+=20per-line=20equipment=20coun?= =?UTF-8?q?ts=20+=20builds=20a=20UnsTreeSnapshot=20including=20a=2016-char?= =?UTF-8?q?=20SHA-256=20revision=20token=20computed=20deterministically=20?= =?UTF-8?q?over=20the=20sorted=20(kind,=20id,=20parent,=20name,=20notes)?= =?UTF-8?q?=20tuple-set=20so=20it's=20stable=20across=20processes=20+=20ch?= =?UTF-8?q?anges=20whenever=20any=20row=20is=20added=20/=20modified=20/=20?= =?UTF-8?q?deleted;=20(2)=20MoveLineAsync(generationId,=20expectedToken,?= =?UTF-8?q?=20lineId,=20targetAreaId)=20=E2=80=94=20re-parents=20one=20lin?= =?UTF-8?q?e=20inside=20the=20same=20draft=20under=20an=20EF=20transaction?= =?UTF-8?q?,=20recomputes=20the=20current=20revision=20token=20from=20fres?= =?UTF-8?q?hly-loaded=20rows,=20and=20throws=20DraftRevisionConflictExcept?= =?UTF-8?q?ion=20when=20the=20caller-supplied=20token=20no=20longer=20matc?= =?UTF-8?q?hes.=20Token=20mismatch=20means=20another=20operator=20mutated?= =?UTF-8?q?=20the=20draft=20between=20preview=20+=20commit=20+=20the=20mov?= =?UTF-8?q?e=20rolls=20back=20rather=20than=20clobbering=20their=20work.?= =?UTF-8?q?=20No-op=20same-area=20drop=20is=20a=20silent=20return.=20Cross?= =?UTF-8?q?-generation=20move=20is=20prevented=20by=20the=20generationId?= =?UTF-8?q?=20filter=20on=20the=20transaction=20reads.=20UnsTab.razor=20ga?= =?UTF-8?q?ins=20draggable=3D"true"=20on=20every=20line=20row=20with=20@on?= =?UTF-8?q?dragstart=20capturing=20the=20LineId=20into=20=5FdragLineId,=20?= =?UTF-8?q?and=20every=20area=20row=20is=20a=20drop=20target=20(@ondragove?= =?UTF-8?q?r=20with=20:preventDefault=20so=20the=20browser=20accepts=20dro?= =?UTF-8?q?ps,=20@ondrop=20kicking=20off=20OnLineDroppedAsync).=20Drop=20p?= =?UTF-8?q?ath=20loads=20a=20fresh=20snapshot,=20builds=20a=20UnsMoveOpera?= =?UTF-8?q?tion(Kind=3DLineMove,=20source/target=20cluster=20matching=20be?= =?UTF-8?q?cause=20cross-cluster=20is=20decision-#82=20rejected),=20runs?= =?UTF-8?q?=20UnsImpactAnalyzer.Analyze=20+=20shows=20a=20Bootstrap=20moda?= =?UTF-8?q?l=20rendered=20inline=20in=20the=20component=20=E2=80=94=20moda?= =?UTF-8?q?l=20shows=20HumanReadableSummary=20+=20equipment/tag=20counts?= =?UTF-8?q?=20+=20any=20CascadeWarnings=20list.=20Confirm=20button=20calls?= =?UTF-8?q?=20MoveLineAsync=20with=20the=20snapshot's=20RevisionToken;=20D?= =?UTF-8?q?raftRevisionConflictException=20surfaces=20a=20separate=20red-h?= =?UTF-8?q?eader=20"Draft=20changed=20=E2=80=94=20refresh=20required"=20mo?= =?UTF-8?q?dal=20with=20a=20Reload=20button=20that=20re-fetches=20areas=20?= =?UTF-8?q?+=20lines=20from=20the=20DB.=20New=20DraftRevisionConflictExcep?= =?UTF-8?q?tion=20in=20UnsService.cs,=20co-located=20with=20the=20service?= =?UTF-8?q?=20that=20throws=20it.=20Five=20new=20UnsServiceMoveTests=20cov?= =?UTF-8?q?ering=20LoadSnapshotAsync=20(areas=20+=20lines=20+=20equipment?= =?UTF-8?q?=20counts),=20RevisionToken=20stability=20between=20two=20reads?= =?UTF-8?q?,=20RevisionToken=20changes=20on=20AddLineAsync,=20MoveLineAsyn?= =?UTF-8?q?c=20happy=20path=20reparents=20the=20line=20in=20the=20DB,=20Mo?= =?UTF-8?q?veLineAsync=20with=20stale=20token=20throws=20DraftRevisionConf?= =?UTF-8?q?lictException=20+=20leaves=20the=20DB=20unchanged.=20Admin=20su?= =?UTF-8?q?ite=2081/81=20passing=20(was=2076,=20+5).=20Admin=20project=20b?= =?UTF-8?q?uilds=200=20errors.=20Task=20#199=20captures=20the=20deferred?= =?UTF-8?q?=20Playwright=20E2E=20smoke=20=E2=80=94=20drag=20a=20line=20ont?= =?UTF-8?q?o=20a=20different=20area=20in=20a=20real=20browser,=20assert=20?= =?UTF-8?q?preview=20modal=20contents,=20click=20Confirm,=20assert=20the?= =?UTF-8?q?=20line=20row=20shows=20the=20new=20area.=20That=20PR=20stands?= =?UTF-8?q?=20up=20a=20new=20tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests=20proj?= =?UTF-8?q?ect=20with=20Playwright=20+=20WebApplicationFactory=20+=20seede?= =?UTF-8?q?d=20InMemory=20DbContext.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Components/Pages/Clusters/UnsTab.razor | 163 +++++++++++++++++- .../Services/UnsService.cs | 130 ++++++++++++++ .../UnsServiceMoveTests.cs | 130 ++++++++++++++ 3 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor index 6c29a82..d1e0612 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor @@ -2,6 +2,13 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject UnsService UnsSvc +
+ Drag any line in the UNS Lines table onto an area row in UNS Areas + to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm + or cancel. If another operator modifies the draft while you're confirming, you'll see a 409 + refresh-required modal instead of clobbering their work. +
+
@@ -14,11 +21,20 @@ else { - + @foreach (var a in _areas) { - + + + + + }
AreaIdName
AreaIdName(drop target)
@a.UnsAreaId@a.Name
@a.UnsAreaId@a.Namedrop here
@@ -35,6 +51,7 @@
}
+

UNS Lines

@@ -50,7 +67,14 @@ @foreach (var l in _lines) { - @l.UnsLineId@l.UnsAreaId@l.Name + + @l.UnsLineId + @l.UnsAreaId + @l.Name + } @@ -75,6 +99,64 @@
+@* Preview / confirm modal for a pending drag-drop move *@ +@if (_pendingPreview is not null) +{ + +} + +@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@ +@if (_conflictMessage is not null) +{ + +} + @code { [Parameter] public long GenerationId { get; set; } [Parameter] public string ClusterId { get; set; } = string.Empty; @@ -87,6 +169,13 @@ private string _newLineName = string.Empty; private string _newLineAreaId = string.Empty; + private string? _dragLineId; + private string? _hoverAreaId; + private UnsImpactPreview? _pendingPreview; + private UnsMoveOperation? _pendingMove; + private bool _committing; + private string? _conflictMessage; + protected override async Task OnParametersSetAsync() => await ReloadAsync(); private async Task ReloadAsync() @@ -112,4 +201,72 @@ _showLineForm = false; await ReloadAsync(); } + + private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId; + + private async Task OnLineDroppedAsync(string targetAreaId) + { + var lineId = _dragLineId; + _hoverAreaId = null; + _dragLineId = null; + if (string.IsNullOrWhiteSpace(lineId)) return; + + var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId); + if (line is null || line.UnsAreaId == targetAreaId) return; + + var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None); + var move = new UnsMoveOperation( + Kind: UnsMoveKind.LineMove, + SourceClusterId: ClusterId, + TargetClusterId: ClusterId, + SourceLineId: lineId, + TargetAreaId: targetAreaId); + try + { + _pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move); + _pendingMove = move; + } + catch (Exception ex) + { + _conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here + } + } + + private void CancelMove() + { + _pendingPreview = null; + _pendingMove = null; + } + + private async Task ConfirmMoveAsync() + { + if (_pendingPreview is null || _pendingMove is null) return; + _committing = true; + try + { + await UnsSvc.MoveLineAsync( + GenerationId, + _pendingPreview.RevisionToken, + _pendingMove.SourceLineId!, + _pendingMove.TargetAreaId!, + CancellationToken.None); + + _pendingPreview = null; + _pendingMove = null; + await ReloadAsync(); + } + catch (DraftRevisionConflictException ex) + { + _pendingPreview = null; + _pendingMove = null; + _conflictMessage = ex.Message; + } + finally { _committing = false; } + } + + private async Task ReloadAfterConflict() + { + _conflictMessage = null; + await ReloadAsync(); + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs index c66ff17..c71a14c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; @@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db) await db.SaveChangesAsync(ct); return line; } + + /// + /// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft + /// and counts equipment + tags per line. Returns the snapshot plus a deterministic + /// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples — + /// stable across processes + changes whenever any row is added / modified / deleted. + /// + public async Task LoadSnapshotAsync(long generationId, CancellationToken ct) + { + var areas = await db.UnsAreas.AsNoTracking() + .Where(a => a.GenerationId == generationId) + .OrderBy(a => a.UnsAreaId) + .ToListAsync(ct); + + var lines = await db.UnsLines.AsNoTracking() + .Where(l => l.GenerationId == generationId) + .OrderBy(l => l.UnsLineId) + .ToListAsync(ct); + + var equipmentCounts = await db.Equipment.AsNoTracking() + .Where(e => e.GenerationId == generationId) + .GroupBy(e => e.UnsLineId) + .Select(g => new { LineId = g.Key, Count = g.Count() }) + .ToListAsync(ct); + var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase); + + var lineSummaries = lines.Select(l => + new UnsLineSummary( + LineId: l.UnsLineId, + Name: l.Name, + EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId), + TagCount: 0)).ToList(); + + var areaSummaries = areas.Select(a => + new UnsAreaSummary( + AreaId: a.UnsAreaId, + Name: a.Name, + LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase)) + .Select(l => l.UnsLineId).ToList())).ToList(); + + return new UnsTreeSnapshot + { + DraftGenerationId = generationId, + RevisionToken = ComputeRevisionToken(areas, lines), + Areas = areaSummaries, + Lines = lineSummaries, + }; + } + + /// + /// Atomic re-parent of a line to a new area inside the same draft. The caller must pass + /// the revision token it observed at preview time — a mismatch raises + /// so the UI can show the 409 concurrent-edit + /// modal instead of silently overwriting a peer's work. + /// + public async Task MoveLineAsync( + long generationId, + DraftRevisionToken expected, + string lineId, + string targetAreaId, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentException.ThrowIfNullOrWhiteSpace(lineId); + ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId); + + var supportsTx = db.Database.IsRelational(); + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null; + if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false); + + try + { + var areas = await db.UnsAreas + .Where(a => a.GenerationId == generationId) + .OrderBy(a => a.UnsAreaId) + .ToListAsync(ct); + + var lines = await db.UnsLines + .Where(l => l.GenerationId == generationId) + .OrderBy(l => l.UnsLineId) + .ToListAsync(ct); + + var current = ComputeRevisionToken(areas, lines); + if (!current.Matches(expected)) + throw new DraftRevisionConflictException( + $"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " + + "Refresh + redo the move."); + + var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}."); + + if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}."); + + if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)) + return; // no-op drop — same area + + line.UnsAreaId = targetAreaId; + await db.SaveChangesAsync(ct); + if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); + } + catch + { + if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false); + throw; + } + finally + { + if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false); + } + } + + private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList areas, IReadOnlyList lines) + { + var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80); + foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)) + sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';'); + foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal)) + sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';'); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())); + return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]); + } } + +/// Thrown when a UNS move's expected revision token no longer matches the live draft +/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style +/// "refresh required" modal in the Admin UI. +public sealed class DraftRevisionConflictException(string message) : Exception(message); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs new file mode 100644 index 0000000..45415f5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs @@ -0,0 +1,130 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class UnsServiceMoveTests +{ + [Fact] + public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" }, + lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") }, + equipmentLines: new[] { "line-a", "line-a", "line-b" }); + var svc = new UnsService(ctx); + + var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None); + + snap.Areas.Count.ShouldBe(2); + snap.Lines.Count.ShouldBe(3); + snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2); + snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1); + snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0); + } + + [Fact] + public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + var first = await svc.LoadSnapshotAsync(1, CancellationToken.None); + var second = await svc.LoadSnapshotAsync(1, CancellationToken.None); + + second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue(); + } + + [Fact] + public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + var before = await svc.LoadSnapshotAsync(1, CancellationToken.None); + await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None); + var after = await svc.LoadSnapshotAsync(1, CancellationToken.None); + + after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse(); + } + + [Fact] + public async Task MoveLineAsync_WithMatchingToken_Reparents_Line() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" }, + lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None); + await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None); + + var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a"); + moved.UnsAreaId.ShouldBe("area-2"); + } + + [Fact] + public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" }, + lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + // Simulate a peer operator's concurrent edit between our preview + commit. + var stale = new DraftRevisionToken("0000000000000000"); + + await Should.ThrowAsync(() => + svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None)); + + var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a"); + row.UnsAreaId.ShouldBe("area-1"); + } + + private static void Seed(OtOpcUaConfigDbContext ctx, long draftId, + IEnumerable areas, + IEnumerable<(string line, string area)> lines, + IEnumerable? equipmentLines = null) + { + foreach (var a in areas) + { + ctx.UnsAreas.Add(new UnsArea + { + GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a, + }); + } + foreach (var (line, area) in lines) + { + ctx.UnsLines.Add(new UnsLine + { + GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line, + }); + } + foreach (var lineId in equipmentLines ?? []) + { + ctx.Equipment.Add(new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = draftId, + EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15], + EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv", + UnsLineId = lineId, Name = "x", MachineCode = "m", + }); + } + ctx.SaveChanges(); + } + + private static OtOpcUaConfigDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new OtOpcUaConfigDbContext(opts); + } +}