using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Playwright; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests; /// /// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 (this file) /// drives the Blazor Server interactive circuit through a real drag-drop → confirm-modal /// → apply flow and a 409 concurrent-edit flow, both via Chromium. /// /// /// /// Prerequisite. Chromium must be installed locally: /// pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium. /// When the binary is missing the tests rather than fail hard, /// so CI pipelines that don't run the install step still report green. /// /// /// Harness notes. points the content root at /// the Admin assembly directory + sets ApplicationName + calls /// UseStaticWebAssets so /_framework/blazor.web.js + /app.css /// resolve from the Admin's staticwebassets.development.json manifest (which /// stitches together Admin wwwroot + the framework NuGet cache). Hubs /// /hubs/fleet + /hubs/alerts are mapped so ClusterDetail's /// HubConnection negotiation doesn't 500 at first render. The InMemory /// database name is captured as a stable string per fixture instance so the seed /// scope + Blazor circuit scope + test-assertion scope all share one backing store. /// /// [Trait("Category", "E2E")] public sealed class UnsTabDragDropE2ETests { [Fact] public async Task Admin_host_serves_HTTP_via_Playwright_scaffolding() { await using var app = new AdminWebAppFactory(); await app.StartAsync(); var fixture = await TryInitPlaywrightAsync(); if (fixture is null) return; try { var ctx = await fixture.Browser.NewContextAsync(); var page = await ctx.NewPageAsync(); var response = await page.GotoAsync(app.BaseUrl); response.ShouldNotBeNull(); response!.Status.ShouldBeLessThan(500, $"Admin host returned HTTP {response.Status} at root — scaffolding broken"); var body = await page.Locator("body").InnerHTMLAsync(); body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor"); } finally { await fixture.DisposeAsync(); } } [Fact] public async Task Dragging_line_onto_new_area_shows_preview_modal_then_confirms_the_move() { await using var app = new AdminWebAppFactory(); await app.StartAsync(); var fixture = await TryInitPlaywrightAsync(); if (fixture is null) return; try { var ctx = await fixture.Browser.NewContextAsync(); var page = await ctx.NewPageAsync(); await OpenUnsTabAsync(page, app); // The seed wires line 'oven-line' to area 'warsaw' (area-a); dragging it onto // 'berlin' (area-b) should surface the preview modal. Playwright's DragToAsync // dispatches native dragstart / dragover / drop events that the razor's // @ondragstart / @ondragover / @ondrop handlers pick up. var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" }); var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" }); await lineRow.DragToAsync(berlinRow); var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" }); await modalTitle.WaitForAsync(new() { Timeout = 10_000 }); var modalBody = await page.Locator(".modal-body").InnerTextAsync(); modalBody.ShouldContain("Equipment re-homed", customMessage: "preview modal should render UnsImpactAnalyzer summary"); await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" }) .ClickAsync(); // Modal dismisses after the MoveLineAsync round-trip + ReloadAsync. await modalTitle.WaitForAsync(new() { State = WaitForSelectorState.Detached, Timeout = 10_000 }); // Persisted state: the line row now shows area-b as its Area column value. using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var line = await db.UnsLines.AsNoTracking() .FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId); line.UnsAreaId.ShouldBe("area-b", "drag-drop should have moved the line to the berlin area via UnsService.MoveLineAsync"); } finally { await fixture.DisposeAsync(); } } [Fact] public async Task Preview_shown_then_peer_edit_applied_surfaces_409_conflict_modal() { await using var app = new AdminWebAppFactory(); await app.StartAsync(); var fixture = await TryInitPlaywrightAsync(); if (fixture is null) return; try { var ctx = await fixture.Browser.NewContextAsync(); var page = await ctx.NewPageAsync(); await OpenUnsTabAsync(page, app); // Open the preview first (same drag as the happy-path test). The preview captures // a RevisionToken under the current draft state. var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" }); var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" }); await lineRow.DragToAsync(berlinRow); var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" }); await modalTitle.WaitForAsync(new() { Timeout = 10_000 }); // Simulate a concurrent operator committing their own edit between the preview // open + our Confirm click — bumps the DraftRevisionToken so our stale token hits // DraftRevisionConflictException in UnsService.MoveLineAsync. using (var scope = app.Services.CreateScope()) { var uns = scope.ServiceProvider.GetRequiredService(); await uns.AddAreaAsync(app.SeededGenerationId, app.SeededClusterId, "madrid", notes: null, CancellationToken.None); } await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" }) .ClickAsync(); var conflictTitle = page.Locator(".modal-title", new() { HasTextString = "Draft changed" }); await conflictTitle.WaitForAsync(new() { Timeout = 10_000 }); // Persisted state: line still points at the original area-a — the conflict short- // circuited the move. using var verifyScope = app.Services.CreateScope(); var db = verifyScope.ServiceProvider.GetRequiredService(); var line = await db.UnsLines.AsNoTracking() .FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId); line.UnsAreaId.ShouldBe("area-a", "conflict path must not overwrite the peer operator's draft state"); } finally { await fixture.DisposeAsync(); } } private static async Task TryInitPlaywrightAsync() { try { var fixture = new PlaywrightFixture(); await fixture.InitializeAsync(); return fixture; } catch (PlaywrightBrowserMissingException) { Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium."); return null; } } /// /// Navigates to the seeded cluster and switches to the UNS Structure tab, waiting for /// the Blazor Server interactive circuit to render the draggable line table. Returns /// once the drop-target cells ("drop here") are visible — that's the signal the /// circuit is live and @ondragstart handlers are wired. /// private static async Task OpenUnsTabAsync(IPage page, AdminWebAppFactory app) { await page.GotoAsync($"{app.BaseUrl}/clusters/{app.SeededClusterId}", new() { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 20_000 }); var unsTab = page.Locator("button.nav-link", new() { HasTextString = "UNS Structure" }); await unsTab.WaitForAsync(new() { Timeout = 15_000 }); await unsTab.ClickAsync(); // "drop here" is the per-area hint cell — only rendered inside so its // visibility confirms both the tab switch and the circuit's interactive render. await page.Locator("td", new() { HasTextString = "drop here" }) .First.WaitForAsync(new() { Timeout = 15_000 }); } }