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:
Joseph Doherty
2026-05-20 19:56:49 -04:00
parent 12b86bea7a
commit 13e84a76a7
4 changed files with 602 additions and 0 deletions

View File

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