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