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 (#23 M7-T3 / Bundle B). The grid /// renders 10 columns, paginates via keyset (passing the last row's /// (OccurredAtUtc, EventId) back to the service), raises a row-click callback /// that Bundle C wires to the drilldown drawer, and styles non-success status /// rows with an error-coded badge. /// public class AuditResultsGridTests : BunitContext { private readonly IAuditLogQueryService _service; private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new(); private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null) => new() { EventId = Guid.NewGuid(), OccurredAtUtc = occurredAtUtc, Channel = channel, Kind = kind, Status = status, SourceSiteId = site, Target = "demo-target", Actor = "tester", ExecutionId = executionId, ParentExecutionId = parentExecutionId, DurationMs = 42, HttpStatus = status == AuditStatus.Delivered ? 200 : 500, ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null, }; public AuditResultsGridTests() { _service = Substitute.For(); _service.DefaultPageSize.Returns(100); Services.AddSingleton(_service); // The grid's OnAfterRenderAsync calls into audit-grid.js (init + the // sessionStorage load). Loose mode lets those unconfigured calls no-op // — auditGrid.load returns null (no prior state) unless a test sets up // an explicit JSInterop.Setup to return a stored payload. JSInterop.Mode = JSRuntimeMode.Loose; } private void StubPage(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 Render_TenColumns_FromStubService() { StubPage(new List { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // 10 column headers per Component-AuditLog.md §10. var expectedHeaders = new[] { "OccurredAtUtc", "Site", "Channel", "Kind", "Status", "Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage", }; foreach (var header in expectedHeaders) { Assert.Contains($"data-test=\"col-header-{header}\"", cut.Markup); } } [Fact] public void Click_NextPage_CallsService_WithCursor_OfLastRow() { // First page: two rows, descending by OccurredAtUtc. The grid must pass the // LAST row (the older one) back as the keyset cursor for the next page. var first = MakeEvent(new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), AuditStatus.Delivered); var second = MakeEvent(new DateTime(2026, 5, 20, 11, 30, 0, DateTimeKind.Utc), AuditStatus.Failed); StubPage(new[] { first, second }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); cut.Find("[data-test=\"grid-next-page\"]").Click(); // Two service calls: initial + next. Assert.Equal(2, _calls.Count); var nextCall = _calls[1]; Assert.NotNull(nextCall.Paging); Assert.Equal(second.OccurredAtUtc, nextCall.Paging!.AfterOccurredAtUtc); Assert.Equal(second.EventId, nextCall.Paging.AfterEventId); } [Fact] public void Click_Row_RaisesOnRowSelected() { var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered); StubPage(new[] { target }); AuditEvent? captured = null; var cut = Render(p => p .Add(c => c.Filter, new AuditLogQueryFilter()) .Add(c => c.OnRowSelected, EventCallback.Factory.Create(this, e => captured = e))); cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click(); Assert.NotNull(captured); Assert.Equal(target.EventId, captured!.EventId); } [Fact] public void Render_IncludesExecutionIdColumn() { StubPage(new List { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // The ExecutionId column header is present alongside the spec columns. Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup); } [Fact] public void ExecutionId_NonNullRow_RendersShortMonospaceValue() { var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555"); var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId); StubPage(new[] { row }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]"); // Short form: first 8 hex digits of the "N" form. Assert.Equal("abcdef01", cell.TextContent.Trim()); // Monospace presentation; full value retained in the title attribute. Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty); Assert.Equal(executionId.ToString(), cell.GetAttribute("title")); } [Fact] public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell() { var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null); StubPage(new[] { row }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // A null ExecutionId renders the em-dash placeholder, not a value cell. Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]")); } [Fact] public void Render_IncludesParentExecutionIdColumn() { StubPage(new List { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // The ParentExecutionId column header is present alongside the spec columns. Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup); } [Fact] public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue() { var parentExecutionId = Guid.Parse("fedcba98-2222-3333-4444-555555555555"); var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: parentExecutionId); StubPage(new[] { row }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); var cell = cut.Find($"[data-test=\"parent-execution-id-{row.EventId}\"]"); // Short form: first 8 hex digits of the "N" form — mirrors ExecutionId. Assert.Equal("fedcba98", cell.TextContent.Trim()); // Monospace presentation; full value retained in the title attribute. Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty); Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title")); } [Fact] public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell() { var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: null); StubPage(new[] { row }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // A null ParentExecutionId renders the em-dash placeholder, not a value cell. Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]")); } [Fact] public void Status_FailedRow_HasErrorBadgeClass() { var failed = MakeEvent(DateTime.UtcNow.AddMinutes(-2), AuditStatus.Failed); var delivered = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered); StubPage(new[] { delivered, failed }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // Failed badge => bg-danger (red). Delivered => bg-success (green). var failedBadge = cut.Find($"[data-test=\"status-badge-{failed.EventId}\"]"); Assert.Contains("bg-danger", failedBadge.GetAttribute("class") ?? string.Empty); var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]"); Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty); } // --- column resize + reorder UX (#23 follow-ups Task 10) --------------- // // The drag interaction itself is browser-side (audit-grid.js) and covered // by the Playwright suite. The bUnit tests below exercise the .NET-side // load/apply/persist logic that the JS callbacks drive: graceful handling // of stored orders, the reorder slot-move maths, and the resize minimum. /// Column keys in default (spec) order — the fallback used everywhere. private static readonly string[] DefaultOrder = { "OccurredAtUtc", "Site", "Channel", "Kind", "Status", "Target", "Actor", "ExecutionId", "ParentExecutionId", "DurationMs", "HttpStatus", "ErrorMessage", }; private static int HeaderIndex(string markup, string key) => markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal); [Fact] public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn() { StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); foreach (var key in DefaultOrder) { // Each carries the stable drag key and a resize handle. Assert.Contains($"data-col-key=\"{key}\"", cut.Markup); Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup); } } [Fact] public void ColumnOrderParameter_DrivesHeaderOrder() { StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); var cut = Render(p => p .Add(c => c.Filter, new AuditLogQueryFilter()) .Add(c => c.ColumnOrder, new[] { "Status", "Site" })); // Status + Site move to the front; the omitted columns still render, // appended in default order — Status precedes Site precedes Channel. Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site")); Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel")); // No column is dropped — all ten headers are present. foreach (var key in DefaultOrder) { Assert.Contains($"data-col-key=\"{key}\"", cut.Markup); } } [Fact] public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists() { StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // Drag Status onto OccurredAtUtc — Status should land in slot 0. await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc")); Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc")); // The new order was persisted to sessionStorage under the order key. // Loose-mode JSInterop records every InvokeVoidAsync; find the save call. var save = JSInterop.Invocations .Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder"); Assert.Contains("Status", (string)save.Arguments[1]!); } [Fact] public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists() { StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // A drag that would shrink the column to 10px must clamp to the 64px floor. await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10)); // The clamped width is reflected as the --audit-col-width custom property. Assert.Contains("--audit-col-width: 64px", cut.Markup); // The width was persisted to sessionStorage under the widths key. Assert.Contains(JSInterop.Invocations, i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths"); } [Fact] public void StoredOrder_WithUnknownKey_DegradesGracefully() { StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); // A stale persisted order naming a removed column ("LegacyCol") plus a // subset of real columns — the unknown key must be dropped and the // omitted real columns appended in default order, never throwing. JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder") .SetResult("[\"Status\",\"LegacyCol\",\"Site\"]"); JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths") .SetResult((string?)null); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // Restored order applied: Status then Site at the front. Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site")); // The unknown key produced no header and did not break rendering. Assert.DoesNotContain("LegacyCol", cut.Markup); // All ten real columns still present. foreach (var key in DefaultOrder) { Assert.Contains($"data-col-key=\"{key}\"", cut.Markup); } } [Fact] public void StoredWidths_ForUnknownColumn_AreIgnored() { StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder") .SetResult((string?)null); // A width for a real column and one for a removed column. JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths") .SetResult("{\"Target\":220,\"LegacyCol\":300}"); var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); // The valid column's width was applied; the stale one silently ignored. Assert.Contains("--audit-col-width: 220px", cut.Markup); Assert.DoesNotContain("300px", cut.Markup); } }