Compare commits
2 Commits
diff-viewe
...
uns-tab-dr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ee510dc1a | ||
| 543665dedd |
@@ -2,6 +2,13 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@inject UnsService UnsSvc
|
@inject UnsService UnsSvc
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-3">
|
||||||
|
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
@@ -14,11 +21,20 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead><tr><th>AreaId</th><th>Name</th></tr></thead>
|
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var a in _areas)
|
@foreach (var a in _areas)
|
||||||
{
|
{
|
||||||
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
|
||||||
|
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
|
||||||
|
@ondragover:preventDefault
|
||||||
|
@ondragleave="() => _hoverAreaId = null"
|
||||||
|
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||||
|
@ondrop:preventDefault>
|
||||||
|
<td><code>@a.UnsAreaId</code></td>
|
||||||
|
<td>@a.Name</td>
|
||||||
|
<td class="small text-muted">drop here</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -35,6 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<h4>UNS Lines</h4>
|
<h4>UNS Lines</h4>
|
||||||
@@ -50,7 +67,14 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var l in _lines)
|
@foreach (var l in _lines)
|
||||||
{
|
{
|
||||||
<tr><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
<tr draggable="true"
|
||||||
|
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||||
|
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||||
|
style="cursor: grab;">
|
||||||
|
<td><code>@l.UnsLineId</code></td>
|
||||||
|
<td><code>@l.UnsAreaId</code></td>
|
||||||
|
<td>@l.Name</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -75,6 +99,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Preview / confirm modal for a pending drag-drop move *@
|
||||||
|
@if (_pendingPreview is not null)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirm UNS move</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CancelMove"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>@_pendingPreview.HumanReadableSummary</p>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Equipment re-homed: <strong>@_pendingPreview.AffectedEquipmentCount</strong>.
|
||||||
|
Tags re-parented: <strong>@_pendingPreview.AffectedTagCount</strong>.
|
||||||
|
</p>
|
||||||
|
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning small mb-0">
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @onclick="CancelMove">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @onclick="ConfirmMoveAsync" disabled="@_committing">Confirm move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
|
||||||
|
@if (_conflictMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content border-danger">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title">Draft changed — refresh required</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>@_conflictMessage</p>
|
||||||
|
<p class="small text-muted">
|
||||||
|
Concurrency guard per DraftRevisionToken prevented overwriting the peer
|
||||||
|
operator's edit. Reload the tab + redo the move on the current draft state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary" @onclick="ReloadAfterConflict">Reload draft</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public long GenerationId { get; set; }
|
[Parameter] public long GenerationId { get; set; }
|
||||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
@@ -87,6 +169,13 @@
|
|||||||
private string _newLineName = string.Empty;
|
private string _newLineName = string.Empty;
|
||||||
private string _newLineAreaId = 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();
|
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||||
|
|
||||||
private async Task ReloadAsync()
|
private async Task ReloadAsync()
|
||||||
@@ -112,4 +201,72 @@
|
|||||||
_showLineForm = false;
|
_showLineForm = false;
|
||||||
await ReloadAsync();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
@@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db)
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<UnsTreeSnapshot> 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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||||
|
/// modal instead of silently overwriting a peer's work.
|
||||||
|
/// </summary>
|
||||||
|
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<UnsArea> areas, IReadOnlyList<UnsLine> 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||||
|
|||||||
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
@@ -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<DraftRevisionConflictException>(() =>
|
||||||
|
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<string> areas,
|
||||||
|
IEnumerable<(string line, string area)> lines,
|
||||||
|
IEnumerable<string>? 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<OtOpcUaConfigDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new OtOpcUaConfigDbContext(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user