feat(centralui): ExecutionDetailModal — execution rows with per-row detail
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
@using ScadaLink.Commons.Entities.Audit
|
||||||
|
|
||||||
|
@* Execution-Tree Node Detail Modal (Task 3).
|
||||||
|
Opened from an execution-tree node double-click. Given an ExecutionId it
|
||||||
|
loads that execution's audit rows and shows a list → per-row detail.
|
||||||
|
Hand-rolled Bootstrap modal — no bootstrap.bundle.js modal API; visibility
|
||||||
|
is pure Blazor state (the IsOpen bool) + the d-block/show CSS classes,
|
||||||
|
mirroring AuditDrilldownDrawer's hand-rolled offcanvas. The per-row detail
|
||||||
|
body is delegated to the shared <AuditEventDetail>. *@
|
||||||
|
|
||||||
|
@if (IsOpen)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop fade show" data-test="execution-detail-backdrop"
|
||||||
|
@onclick="HandleClose"></div>
|
||||||
|
<div class="modal fade show d-block execution-detail-modal" tabindex="-1"
|
||||||
|
data-test="execution-detail-modal" role="dialog">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small text-uppercase">Execution</div>
|
||||||
|
<h5 class="modal-title mb-0 d-flex align-items-baseline gap-2">
|
||||||
|
<span class="font-monospace">Execution @ShortExecutionId()</span>
|
||||||
|
@if (!_loading && _error is null)
|
||||||
|
{
|
||||||
|
<span class="badge rounded-pill text-bg-secondary fw-normal"
|
||||||
|
data-test="execution-detail-row-count">
|
||||||
|
@_rows.Count @(_rows.Count == 1 ? "row" : "rows")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close"
|
||||||
|
data-test="execution-detail-close"
|
||||||
|
@onclick="HandleClose"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body small">
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<div class="text-muted py-4 text-center" data-test="execution-detail-loading">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Loading execution rows…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_error is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mb-0" role="alert"
|
||||||
|
data-test="execution-detail-error">
|
||||||
|
@_error
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted py-4 text-center" data-test="execution-detail-empty">
|
||||||
|
This execution emitted no audit rows.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_selectedRow is not null)
|
||||||
|
{
|
||||||
|
@* Detail view — shared single-row body. *@
|
||||||
|
@if (_rows.Count > 1)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm px-0 mb-2 execution-detail-back-link"
|
||||||
|
data-test="execution-detail-back"
|
||||||
|
@onclick="BackToList">
|
||||||
|
← Back to rows
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<AuditEventDetail Event="_selectedRow" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* List view — one button per audit row. *@
|
||||||
|
<div class="list-group execution-detail-row-list">
|
||||||
|
@foreach (var row in _rows)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="list-group-item list-group-item-action d-flex align-items-center gap-3"
|
||||||
|
data-test="execution-detail-row-@row.EventId"
|
||||||
|
@onclick="() => SelectRow(row)">
|
||||||
|
<span class="badge @StatusBadgeClass(row.Status) execution-detail-status">
|
||||||
|
@row.Status
|
||||||
|
</span>
|
||||||
|
<span class="execution-detail-kind fw-semibold">@row.Kind</span>
|
||||||
|
<span class="text-muted text-truncate flex-grow-1">
|
||||||
|
@(row.Target ?? "—")
|
||||||
|
</span>
|
||||||
|
<span class="text-muted font-monospace small flex-shrink-0">
|
||||||
|
@FormatTime(row.OccurredAtUtc)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
data-test="execution-detail-close-footer"
|
||||||
|
@onclick="HandleClose">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using ScadaLink.CentralUI.Services;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature,
|
||||||
|
/// Task 3). Opened from an execution-tree node double-click: given an
|
||||||
|
/// <see cref="ExecutionId"/> it loads that execution's audit rows via
|
||||||
|
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Chrome.</b> A hand-rolled Bootstrap modal — visibility is pure Blazor
|
||||||
|
/// state (<see cref="IsOpen"/>) plus the <c>d-block</c>/<c>show</c> CSS classes
|
||||||
|
/// and a sibling <c>modal-backdrop</c>, mirroring how
|
||||||
|
/// <see cref="AuditDrilldownDrawer"/> hand-rolls its offcanvas. No
|
||||||
|
/// <c>bootstrap.bundle.js</c> modal API is used.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Load timing.</b> The modal queries only on the closed → open transition
|
||||||
|
/// (detected in <see cref="OnParametersSetAsync"/>), never on every parameter
|
||||||
|
/// change, so re-renders while open do not re-hit the service.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>States.</b> Two-or-more rows → list view (one button per row, click sets
|
||||||
|
/// the selected row); exactly one row → opens straight to the detail view;
|
||||||
|
/// zero rows → a friendly empty state. A query failure degrades to an inline
|
||||||
|
/// error banner — it is never rethrown, so a transient DB outage cannot kill
|
||||||
|
/// the SignalR circuit (the same posture as <c>ExecutionTreePage.LoadChainAsync</c>).
|
||||||
|
/// The per-row detail body is delegated to the shared <see cref="AuditEventDetail"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class ExecutionDetailModal
|
||||||
|
{
|
||||||
|
[Inject] private IAuditLogQueryService AuditLogQueryService { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The execution whose audit rows the modal loads. When null an open modal
|
||||||
|
/// loads nothing and shows the empty state — the host is expected to pair a
|
||||||
|
/// non-null id with <see cref="IsOpen"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public Guid? ExecutionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the host wants the modal visible. The closed → open transition
|
||||||
|
/// triggers the row load; see <see cref="OnParametersSetAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public bool IsOpen { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user dismisses the modal (header X, backdrop click, or
|
||||||
|
/// footer Close). The host is expected to flip <see cref="IsOpen"/> to false.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
|
// The loaded rows for the current execution; empty until a load completes.
|
||||||
|
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
|
||||||
|
|
||||||
|
// The row whose detail is shown; null = list view.
|
||||||
|
private AuditEvent? _selectedRow;
|
||||||
|
|
||||||
|
private bool _loading;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
// Tracks the previous IsOpen so OnParametersSet can detect the open
|
||||||
|
// transition and load exactly once per open, not on every parameter change.
|
||||||
|
private bool _wasOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Page size for the execution-row query. One execution's audit rows are
|
||||||
|
/// few (cached calls top out around 4–5 rows); 100 comfortably covers a
|
||||||
|
/// whole execution without paging.
|
||||||
|
/// </summary>
|
||||||
|
private const int RowPageSize = 100;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
// Load only on the closed → open transition. A re-render while already
|
||||||
|
// open (or while closed) must not re-hit the service.
|
||||||
|
if (IsOpen && !_wasOpen)
|
||||||
|
{
|
||||||
|
await LoadRowsAsync();
|
||||||
|
}
|
||||||
|
_wasOpen = IsOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the current execution's audit rows. On success, a single-row
|
||||||
|
/// result opens straight to the detail view; otherwise the list view shows.
|
||||||
|
/// A query failure degrades to an inline error banner and is never
|
||||||
|
/// rethrown — audit drill-in is best-effort and must not kill the circuit.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadRowsAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_selectedRow = null;
|
||||||
|
_rows = Array.Empty<AuditEvent>();
|
||||||
|
|
||||||
|
if (ExecutionId is null)
|
||||||
|
{
|
||||||
|
// Nothing to load — fall through to the empty state.
|
||||||
|
_loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_rows = await AuditLogQueryService.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(ExecutionId: ExecutionId.Value),
|
||||||
|
new AuditLogPaging(PageSize: RowPageSize));
|
||||||
|
|
||||||
|
// A single-row execution opens straight to its detail — there is
|
||||||
|
// no list to choose from.
|
||||||
|
if (_rows.Count == 1)
|
||||||
|
{
|
||||||
|
_selectedRow = _rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Mirror ExecutionTreePage.LoadChainAsync: a transient DB outage
|
||||||
|
// degrades the modal to an inline error banner rather than killing
|
||||||
|
// the SignalR circuit. Never rethrow.
|
||||||
|
_error = $"Could not load this execution's audit rows: {ex.Message}";
|
||||||
|
_rows = Array.Empty<AuditEvent>();
|
||||||
|
_selectedRow = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectRow(AuditEvent row) => _selectedRow = row;
|
||||||
|
|
||||||
|
private void BackToList() => _selectedRow = null;
|
||||||
|
|
||||||
|
private async Task HandleClose()
|
||||||
|
{
|
||||||
|
if (OnClose.HasDelegate)
|
||||||
|
{
|
||||||
|
await OnClose.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>First 8 hex digits of the execution id, mirroring the UI's short-id convention.</summary>
|
||||||
|
private string ShortExecutionId()
|
||||||
|
{
|
||||||
|
if (ExecutionId is null)
|
||||||
|
{
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
var n = ExecutionId.Value.ToString("N");
|
||||||
|
return n.Length >= 8 ? n[..8] : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTime(DateTime occurredAtUtc)
|
||||||
|
=> occurredAtUtc.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bootstrap badge class for a row's status — green for the success
|
||||||
|
/// terminal state, red for failure/discard, amber for in-flight. Mirrors
|
||||||
|
/// the status-badge colouring used by the Audit Log results grid.
|
||||||
|
/// </summary>
|
||||||
|
private static string StatusBadgeClass(AuditStatus status) => status switch
|
||||||
|
{
|
||||||
|
AuditStatus.Delivered => "text-bg-success",
|
||||||
|
AuditStatus.Failed or AuditStatus.Discarded or AuditStatus.Parked => "text-bg-danger",
|
||||||
|
_ => "text-bg-warning",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* Execution-Tree Node Detail Modal (Task 3).
|
||||||
|
The modal/backdrop base classes come from Bootstrap; this is hand-rolled
|
||||||
|
(no bootstrap.bundle.js modal API), so the backdrop needs an explicit
|
||||||
|
stacking context and the dialog a comfortable max width. The per-row detail
|
||||||
|
body styles travel with AuditEventDetail.razor.css. */
|
||||||
|
|
||||||
|
/* Bootstrap's .modal-backdrop sits below .modal by default; with the hand-
|
||||||
|
rolled approach we render both as siblings, so pin the dialog above it. */
|
||||||
|
.execution-detail-modal {
|
||||||
|
z-index: 1055;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The audit detail body can carry larger JSON/SQL payloads — a slightly wider
|
||||||
|
dialog than the Bootstrap default keeps those readable. Clamp to the
|
||||||
|
viewport so narrow windows still get the close button on screen. */
|
||||||
|
.execution-detail-modal .modal-dialog {
|
||||||
|
max-width: min(720px, 95vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row-list buttons: a calm hover lift and a fixed-width status badge so the
|
||||||
|
Kind / Target columns align down the list. */
|
||||||
|
.execution-detail-row-list .list-group-item-action {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the back-to-list affordance quiet — it is navigation chrome, not a
|
||||||
|
primary action. */
|
||||||
|
.execution-detail-back-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-detail-back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
@@ -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