feat(centralui): ExecutionDetailModal — execution rows with per-row detail
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Components.Audit;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="ExecutionDetailModal"/> (Execution-Tree Node Detail
|
||||
/// Modal, Task 3). The modal opens on an execution-tree node double-click: given
|
||||
/// an <c>ExecutionId</c> it loads that execution's audit rows via
|
||||
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
|
||||
///
|
||||
/// Tests pin the behaviours the spec cannot lose: load-on-open-transition,
|
||||
/// the four data states (multi-row list, single-row straight-to-detail,
|
||||
/// zero-row empty, query-failure error), and that closing raises OnClose.
|
||||
/// </summary>
|
||||
public class ExecutionDetailModalTests : BunitContext
|
||||
{
|
||||
private readonly IAuditLogQueryService _service;
|
||||
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
|
||||
|
||||
public ExecutionDetailModalTests()
|
||||
{
|
||||
_service = Substitute.For<IAuditLogQueryService>();
|
||||
_service.DefaultPageSize.Returns(100);
|
||||
Services.AddSingleton(_service);
|
||||
|
||||
// AuditEventDetail (the per-row detail body) owns a clipboard interop
|
||||
// call. Loose mode lets that no-op for tests that don't exercise it.
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
}
|
||||
|
||||
private static AuditEvent MakeEvent(
|
||||
Guid executionId,
|
||||
AuditStatus status = AuditStatus.Delivered,
|
||||
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||
AuditKind kind = AuditKind.ApiCall,
|
||||
string? target = "demo-target")
|
||||
=> new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
|
||||
Channel = channel,
|
||||
Kind = kind,
|
||||
Status = status,
|
||||
ExecutionId = executionId,
|
||||
SourceSiteId = "plant-a",
|
||||
Target = target,
|
||||
Actor = "tester",
|
||||
DurationMs = 42,
|
||||
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
||||
};
|
||||
|
||||
private void StubRows(IReadOnlyList<AuditEvent> rows)
|
||||
{
|
||||
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
|
||||
return Task.FromResult(rows);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClosedModal_RendersNothing_AndDoesNotQuery()
|
||||
{
|
||||
StubRows(new[] { MakeEvent(Guid.NewGuid()) });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, Guid.NewGuid())
|
||||
.Add(c => c.IsOpen, false));
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"));
|
||||
Assert.Empty(_calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenTransition_QueriesByExecutionId_WithPageSize100()
|
||||
{
|
||||
var executionId = Guid.Parse("11111111-2222-3333-4444-555555555555");
|
||||
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, false));
|
||||
|
||||
// Closed on first render — no query yet.
|
||||
Assert.Empty(_calls);
|
||||
|
||||
// Flip open: the modal loads exactly once for the open transition.
|
||||
cut.Render(p => p.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.Single(_calls);
|
||||
Assert.Equal(executionId, _calls[0].Filter.ExecutionId);
|
||||
Assert.NotNull(_calls[0].Paging);
|
||||
Assert.Equal(100, _calls[0].Paging!.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StillOpen_NonOpenParameterChange_DoesNotRequery()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.Single(_calls);
|
||||
|
||||
// A parameter set that does NOT flip IsOpen must not re-query.
|
||||
cut.Render(p => p.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.Single(_calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiRow_RendersListView_WithOneButtonPerRow()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
|
||||
var rowB = MakeEvent(executionId, AuditStatus.Failed);
|
||||
StubRows(new[] { rowA, rowB });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
// List view: a row button per audit row, keyed by EventId.
|
||||
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
|
||||
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]"));
|
||||
// Not in detail view yet — no shared detail body rendered.
|
||||
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiRow_ClickRow_ShowsAuditEventDetail()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
|
||||
var rowB = MakeEvent(executionId, AuditStatus.Failed);
|
||||
StubRows(new[] { rowA, rowB });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]").Click();
|
||||
|
||||
// The shared AuditEventDetail body is now rendered (its field list).
|
||||
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
|
||||
// And a Back control to return to the list.
|
||||
Assert.NotNull(cut.Find("[data-test=\"execution-detail-back\"]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiRow_BackControl_ReturnsToList()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
|
||||
var rowB = MakeEvent(executionId, AuditStatus.Failed);
|
||||
StubRows(new[] { rowA, rowB });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]").Click();
|
||||
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
|
||||
|
||||
cut.Find("[data-test=\"execution-detail-back\"]").Click();
|
||||
|
||||
// Back in the list view: row buttons present, detail body gone.
|
||||
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
|
||||
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRow_OpensStraightToDetail_NoBackControl()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var only = MakeEvent(executionId);
|
||||
StubRows(new[] { only });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
// Straight to detail — the shared body is rendered without a click.
|
||||
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
|
||||
// Nothing to go back to: the Back control is hidden for a single row.
|
||||
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-back\"]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRow_ShowsFriendlyEmptyState()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
StubRows(Array.Empty<AuditEvent>());
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
var empty = cut.Find("[data-test=\"execution-detail-empty\"]");
|
||||
Assert.Contains("This execution emitted no audit rows.", empty.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryThrows_ShowsInlineErrorState_DoesNotRethrow()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<IReadOnlyList<AuditEvent>>>(_ => throw new InvalidOperationException("db is down"));
|
||||
|
||||
// Rendering with IsOpen=true must not throw — the modal degrades to an
|
||||
// inline error banner rather than killing the SignalR circuit.
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
var error = cut.Find("[data-test=\"execution-detail-error\"]");
|
||||
Assert.Contains("db is down", error.TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_RaisesOnClose()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
|
||||
|
||||
var closed = false;
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true)
|
||||
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
|
||||
|
||||
cut.Find("[data-test=\"execution-detail-close\"]").Click();
|
||||
|
||||
Assert.True(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackdropClick_RaisesOnClose()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
|
||||
|
||||
var closed = false;
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true)
|
||||
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
|
||||
|
||||
cut.Find("[data-test=\"execution-detail-backdrop\"]").Click();
|
||||
|
||||
Assert.True(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_ShowsShortExecutionId_AndRowCount()
|
||||
{
|
||||
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
|
||||
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId), MakeEvent(executionId) });
|
||||
|
||||
var cut = Render<ExecutionDetailModal>(p => p
|
||||
.Add(c => c.ExecutionId, executionId)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
var modal = cut.Find("[data-test=\"execution-detail-modal\"]");
|
||||
// Short id (first 8 hex of the "N" form) appears in the header.
|
||||
Assert.Contains("abcdef01", modal.TextContent);
|
||||
// Row count surfaces in the header chrome.
|
||||
Assert.Contains("3", modal.TextContent);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user