feat(ui): AuditFilterBar component (#23 M7)
Adds the filter bar for the central Audit Log page (#23 M7-T2): * AuditQueryModel — UI binding model with chip-style multi-select state for Channel/Kind/Status/Site, a Channel→Kind narrowing map (CachedSubmit and CachedResolve appear under both ApiOutbound and DbOutbound per Component-AuditLog.md §4), time-range presets (5min/1h/24h/Custom), free-text Instance/Script/Target/Actor searches and an Errors-only toggle. Collapses to the single-value AuditLogQueryFilter on ToFilter(utcNow); multi-select chips take the first selected per dimension and the Errors-only toggle pins Failed when Status chips are empty (chip-set wins otherwise) — documented Bundle B scope decision. * AuditFilterBar.razor + .razor.cs — Blazor Server component (Bootstrap only, no third-party UI libs). Renders the 10 spec elements plus the Errors-only toggle, populates Site chips from ISiteRepository at initialisation, exposes [Parameter] EventCallback<AuditLogQueryFilter> OnFilterChanged and an optional NowUtcProvider seam for time-window tests. * AuditFilterBarTests — 5 bUnit tests pinning element presence, Apply callback payload, Channel→Kind narrowing, Errors-only toggle precedence and the LastHour time-window collapse.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Components.Audit;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
|
||||
///
|
||||
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. Tests
|
||||
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c>
|
||||
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
|
||||
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when
|
||||
/// Status is otherwise empty; (5) the "Last hour" preset populates
|
||||
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window
|
||||
/// collapse without freezing the clock.
|
||||
/// </summary>
|
||||
public class AuditFilterBarTests : BunitContext
|
||||
{
|
||||
private readonly ISiteRepository _siteRepo;
|
||||
|
||||
public AuditFilterBarTests()
|
||||
{
|
||||
_siteRepo = Substitute.For<ISiteRepository>();
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||
{
|
||||
new("Plant A", "plant-a") { Id = 1 },
|
||||
new("Plant B", "plant-b") { Id = 2 },
|
||||
}));
|
||||
Services.AddSingleton(_siteRepo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present()
|
||||
{
|
||||
var cut = Render<AuditFilterBar>();
|
||||
|
||||
// Each filter element is tagged with a stable data-test attribute so the test
|
||||
// doesn't churn on cosmetic label changes.
|
||||
var markers = new[]
|
||||
{
|
||||
"data-test=\"filter-channel\"",
|
||||
"data-test=\"filter-kind\"",
|
||||
"data-test=\"filter-status\"",
|
||||
"data-test=\"filter-site\"",
|
||||
"data-test=\"filter-time-range\"",
|
||||
"data-test=\"filter-custom-range\"",
|
||||
"data-test=\"filter-instance\"",
|
||||
"data-test=\"filter-script\"",
|
||||
"data-test=\"filter-target\"",
|
||||
"data-test=\"filter-actor\"",
|
||||
"data-test=\"filter-errors-only\"",
|
||||
};
|
||||
foreach (var marker in markers)
|
||||
{
|
||||
Assert.Contains(marker, cut.Markup);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_RaisesOnFilterChanged_WithSelectedFilters()
|
||||
{
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
// Drive UI: toggle a Channel chip, type in the Target search box, click Apply.
|
||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
||||
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel);
|
||||
Assert.Equal("Plant-A-OPC", captured.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Channel_Narrows_Kind_Options_When_Selected()
|
||||
{
|
||||
var cut = Render<AuditFilterBar>();
|
||||
|
||||
// With no Channel selected, every kind chip is in the DOM.
|
||||
foreach (var kind in Enum.GetValues<AuditKind>())
|
||||
{
|
||||
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
|
||||
}
|
||||
|
||||
// Select only ApiOutbound; Kind chips outside the channel-kind map drop out.
|
||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
||||
|
||||
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
|
||||
foreach (var kind in apiKinds)
|
||||
{
|
||||
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
|
||||
}
|
||||
// Sanity: an unrelated kind is gone.
|
||||
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup);
|
||||
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty()
|
||||
{
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
// Toggle Errors-only ON, leaving Status chips empty.
|
||||
cut.Find("[data-test=\"filter-errors-only\"] input").Change(true);
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
// Single-value filter contract: Failed leads the non-success set.
|
||||
Assert.Equal(AuditStatus.Failed, captured!.Status);
|
||||
|
||||
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
|
||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.Equal(AuditStatus.Delivered, captured!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
|
||||
{
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
// LastHour is the default preset; clicking Apply must collapse it to FromUtc.
|
||||
var before = DateTime.UtcNow;
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.NotNull(captured!.FromUtc);
|
||||
// FromUtc should be in [now-1h-eps, now-1h+eps] computed against the Apply moment.
|
||||
var expectedLow = before.AddHours(-1).AddSeconds(-1);
|
||||
var expectedHigh = after.AddHours(-1).AddSeconds(1);
|
||||
Assert.InRange(captured.FromUtc!.Value, expectedLow, expectedHigh);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user