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); } }