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;
///
/// bUnit tests for (Execution-Tree Node Detail
/// Modal, Task 3). The modal opens on an execution-tree node double-click: given
/// an ExecutionId it loads that execution's audit rows via
/// 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.
///
public class ExecutionDetailModalTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
public ExecutionDetailModalTests()
{
_service = Substitute.For();
_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 rows)
{
_service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any())
.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(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(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(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(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(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(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(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());
var cut = Render(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(), Arg.Any(), Arg.Any())
.Returns>>(_ => 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(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(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(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 EscapeKey_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render(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-modal\"]").KeyDown("Escape");
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(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);
}
}