Adds drag-to-resize and drag-to-reorder column UX to AuditResultsGrid, with chosen widths + column order persisted in browser sessionStorage. - wwwroot/js/audit-grid.js: dependency-free helper — pointer-driven resize handles, native HTML5 drag-and-drop reorder, and a sessionStorage save/load wrapper (mirrors treeview-storage.js). - AuditResultsGrid: renders a resize handle per <th>, makes headers draggable, applies persisted widths via a --audit-col-width custom property, and wires reorder into the existing ColumnOrder / OrderedColumns() mechanism. JS-invokable OnColumnResized / OnColumnReordered persist + re-render. A stored order naming an unknown column degrades gracefully (drops unknown keys, appends missing columns in default order); widths clamp to a 64px minimum. - AuditResultsGrid.razor.css: subtle scoped styling for the resize handle affordance and the reorder drop-target highlight. - App.razor references audit-grid.js alongside the other scripts. - Tests: 6 new bUnit tests for the load/apply/persist logic and graceful degradation; a new AuditGridColumnTests Playwright suite for the drag UX + reload persistence. Audit page bUnit tests set loose JSInterop mode since the grid now calls into audit-grid.js.
270 lines
12 KiB
C#
270 lines
12 KiB
C#
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="AuditResultsGrid"/> (#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.
|
|
/// </summary>
|
|
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")
|
|
=> new()
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = occurredAtUtc,
|
|
Channel = channel,
|
|
Kind = kind,
|
|
Status = status,
|
|
SourceSiteId = site,
|
|
Target = "demo-target",
|
|
Actor = "tester",
|
|
DurationMs = 42,
|
|
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
|
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
|
|
};
|
|
|
|
public AuditResultsGridTests()
|
|
{
|
|
_service = Substitute.For<IAuditLogQueryService>();
|
|
_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<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 Render_TenColumns_FromStubService()
|
|
{
|
|
StubPage(new List<AuditEvent>
|
|
{
|
|
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
|
});
|
|
|
|
var cut = Render<AuditResultsGrid>(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<AuditResultsGrid>(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<AuditResultsGrid>(p => p
|
|
.Add(c => c.Filter, new AuditLogQueryFilter())
|
|
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(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 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<AuditResultsGrid>(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.
|
|
|
|
/// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
|
|
private static readonly string[] DefaultOrder =
|
|
{
|
|
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
|
|
"Target", "Actor", "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<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
|
|
|
foreach (var key in DefaultOrder)
|
|
{
|
|
// Each <th> 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<AuditResultsGrid>(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<AuditResultsGrid>(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<AuditResultsGrid>(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<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
|
|
.SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
|
|
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
|
|
.SetResult((string?)null);
|
|
|
|
var cut = Render<AuditResultsGrid>(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<string?>("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<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
|
|
.SetResult("{\"Target\":220,\"LegacyCol\":300}");
|
|
|
|
var cut = Render<AuditResultsGrid>(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);
|
|
}
|
|
}
|