feat(kpi): K15 — Audit Log trend charts
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
@page "/audit/log"
|
@page "/audit/log"
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
|
||||||
@using ZB.MOM.WW.ScadaBridge.Security
|
@using ZB.MOM.WW.ScadaBridge.Security
|
||||||
@@ -11,6 +12,56 @@
|
|||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<h1 class="h4 mb-3">Audit Log</h1>
|
<h1 class="h4 mb-3">Audit Log</h1>
|
||||||
|
|
||||||
|
@* Trends panel (M6 / K15). A best-effort collapsible Bootstrap card sitting
|
||||||
|
above the audit query UI: one KpiTrendChart per AuditLog global metric over
|
||||||
|
a 24h (default) / 7d window. Series are fetched independently in the
|
||||||
|
code-behind — a failed fetch degrades only that chart to the unavailable
|
||||||
|
placeholder and never breaks the filter bar / grid below. *@
|
||||||
|
<div class="card mb-3" data-test="audit-trends">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link p-0 text-decoration-none fw-semibold"
|
||||||
|
aria-expanded="@_trendsOpen"
|
||||||
|
@onclick="ToggleTrendsAsync">
|
||||||
|
Trends
|
||||||
|
<span class="text-muted small">@(_trendsOpen ? "▾" : "▸")</span>
|
||||||
|
</button>
|
||||||
|
@if (_trendsOpen)
|
||||||
|
{
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Trend window">
|
||||||
|
<button type="button"
|
||||||
|
class="btn @(_windowHours == 24 ? "btn-secondary" : "btn-outline-secondary")"
|
||||||
|
@onclick="() => SetWindowAsync(24)">
|
||||||
|
24h
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn @(_windowHours == 168 ? "btn-secondary" : "btn-outline-secondary")"
|
||||||
|
@onclick="() => SetWindowAsync(168)">
|
||||||
|
7d
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (_trendsOpen)
|
||||||
|
{
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
@foreach (var (metric, title, unit) in TrendMetrics)
|
||||||
|
{
|
||||||
|
var series = SeriesFor(metric);
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<KpiTrendChart Points="@series.Points"
|
||||||
|
Title="@title"
|
||||||
|
Unit="@unit"
|
||||||
|
IsAvailable="@series.IsAvailable"
|
||||||
|
ErrorMessage="@series.ErrorMessage" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid.
|
@* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid.
|
||||||
Bundle D (M7-T10..T12) threads a query-string instance prefill through
|
Bundle D (M7-T10..T12) threads a query-string instance prefill through
|
||||||
InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
|
InitialInstanceSearch — UI-only because the filter contract has no instance column. *@
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.WebUtilities;
|
|||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit;
|
||||||
|
|
||||||
@@ -48,6 +49,13 @@ public partial class AuditLogPage : IDisposable
|
|||||||
{
|
{
|
||||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// KPI-history facade for the M6 (K15) Trends panel — fetched per metric,
|
||||||
|
/// per window. Best-effort: a failed series fetch degrades that one chart to
|
||||||
|
/// the unavailable placeholder and never breaks the audit query UI.
|
||||||
|
/// </summary>
|
||||||
|
[Inject] private IKpiHistoryQueryService KpiHistory { get; set; } = null!;
|
||||||
|
|
||||||
private AuditLogQueryFilter? _currentFilter;
|
private AuditLogQueryFilter? _currentFilter;
|
||||||
private AuditEventView? _selectedEvent;
|
private AuditEventView? _selectedEvent;
|
||||||
private bool _drawerOpen;
|
private bool _drawerOpen;
|
||||||
@@ -60,6 +68,18 @@ public partial class AuditLogPage : IDisposable
|
|||||||
Navigation.LocationChanged += HandleLocationChanged;
|
Navigation.LocationChanged += HandleLocationChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// The Trends panel is open by default, so load its series on first render.
|
||||||
|
// Done in OnInitializedAsync (not OnInitialized) so the per-metric awaits
|
||||||
|
// do not block the synchronous query-string drill-in parsing above.
|
||||||
|
if (_trendsOpen)
|
||||||
|
{
|
||||||
|
await LoadTrendsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Re-applies the query-string drill-in filters when the URL changes while
|
/// Re-applies the query-string drill-in filters when the URL changes while
|
||||||
/// this page stays routed (e.g. the drawer's "View parent execution" action
|
/// this page stays routed (e.g. the drawer's "View parent execution" action
|
||||||
@@ -237,6 +257,93 @@ public partial class AuditLogPage : IDisposable
|
|||||||
_drawerOpen = false;
|
_drawerOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// M6 (K15) — Audit Log trend charts.
|
||||||
|
//
|
||||||
|
// A best-effort Trends panel that sits above the audit query UI: one
|
||||||
|
// KpiTrendChart per AuditLog global metric, over a 24h (default) or 7d
|
||||||
|
// window. Each series is fetched independently and a failure degrades only
|
||||||
|
// that chart (IsAvailable=false + short message) — the audit query UI is
|
||||||
|
// never affected. Re-queries on init (panel open by default), on the window
|
||||||
|
// toggle, and when the panel is re-shown after being collapsed.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Whether the Trends panel is expanded (open by default).</summary>
|
||||||
|
private bool _trendsOpen = true;
|
||||||
|
|
||||||
|
/// <summary>Active window in hours — 24 (default) or 168 (7 days).</summary>
|
||||||
|
private int _windowHours = 24;
|
||||||
|
|
||||||
|
/// <summary>One rendered chart's state: the fetched series + availability.</summary>
|
||||||
|
private readonly record struct TrendSeries(
|
||||||
|
IReadOnlyList<KpiSeriesPoint>? Points, bool IsAvailable, string? ErrorMessage);
|
||||||
|
|
||||||
|
/// <summary>The metrics rendered in the panel, in display order.</summary>
|
||||||
|
private static readonly (string Metric, string Title, string? Unit)[] TrendMetrics =
|
||||||
|
{
|
||||||
|
("totalEventsLastHour", "Events / hour", null),
|
||||||
|
("errorEventsLastHour", "Error events / hour", null),
|
||||||
|
("backlogTotal", "Backlog", null),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Per-metric series state, keyed by the metric name.</summary>
|
||||||
|
private readonly Dictionary<string, TrendSeries> _trendSeries = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches every Trends metric for the current window. Each
|
||||||
|
/// <see cref="IKpiHistoryQueryService.GetSeriesAsync"/> call is wrapped so a
|
||||||
|
/// failure surfaces as an unavailable chart rather than throwing out of the
|
||||||
|
/// page — audit-log functionality must never depend on KPI history being up.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadTrendsAsync()
|
||||||
|
{
|
||||||
|
var toUtc = DateTime.UtcNow;
|
||||||
|
var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);
|
||||||
|
|
||||||
|
foreach (var (metric, _, _) in TrendMetrics)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var points = await KpiHistory.GetSeriesAsync(
|
||||||
|
KpiSources.AuditLog, metric, KpiScopes.Global, scopeKey: null,
|
||||||
|
fromUtc, toUtc);
|
||||||
|
_trendSeries[metric] = new TrendSeries(points, IsAvailable: true, ErrorMessage: null);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Best-effort: degrade this chart only, keep the rest of the page alive.
|
||||||
|
_trendSeries[metric] = new TrendSeries(
|
||||||
|
Points: null, IsAvailable: false, ErrorMessage: "Trend data unavailable.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the rendered state for a metric, defaulting to available-empty.</summary>
|
||||||
|
private TrendSeries SeriesFor(string metric) =>
|
||||||
|
_trendSeries.TryGetValue(metric, out var s) ? s : new TrendSeries(null, true, null);
|
||||||
|
|
||||||
|
/// <summary>Expands/collapses the panel; re-queries when it is (re)shown.</summary>
|
||||||
|
private async Task ToggleTrendsAsync()
|
||||||
|
{
|
||||||
|
_trendsOpen = !_trendsOpen;
|
||||||
|
if (_trendsOpen)
|
||||||
|
{
|
||||||
|
await LoadTrendsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Switches the window (24h ↔ 7d) and re-queries every series.</summary>
|
||||||
|
private async Task SetWindowAsync(int hours)
|
||||||
|
{
|
||||||
|
if (_windowHours == hours)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowHours = hours;
|
||||||
|
await LoadTrendsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most
|
/// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most
|
||||||
/// recently applied filter as query-string params so the server-side
|
/// recently applied filter as query-string params so the server-side
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ public class AuditLogPagePermissionTests : BunitContext
|
|||||||
// a permitted render is exercised end-to-end.
|
// a permitted render is exercised end-to-end.
|
||||||
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
||||||
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
|
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
|
||||||
|
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
|
||||||
|
// panel — register a stand-in so a permitted render is exercised end-to-end.
|
||||||
|
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
// Provide stand-ins so the scaffold smoke tests still render the page.
|
// Provide stand-ins so the scaffold smoke tests still render the page.
|
||||||
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>());
|
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>());
|
||||||
Services.AddSingleton(_queryService);
|
Services.AddSingleton(_queryService);
|
||||||
|
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
|
||||||
|
// panel. Provide a stand-in so the scaffold/drill-in renders still work.
|
||||||
|
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(query))
|
if (!string.IsNullOrEmpty(query))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Bunit.TestDoubles;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.AuditLogPage;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the M6 (K15) Trends panel added to the Audit Log page — one
|
||||||
|
/// <c>KpiTrendChart</c> per AuditLog global metric, over a 24h/7d window. The
|
||||||
|
/// panel is best-effort: a failing <see cref="IKpiHistoryQueryService"/> must
|
||||||
|
/// degrade the affected charts without breaking the audit query UI (the filter
|
||||||
|
/// bar + results grid that share the page).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// bUnit hosts the page directly, so the test registers every service the page
|
||||||
|
/// and its child components inject: <see cref="AuthenticationStateProvider"/> +
|
||||||
|
/// the real authorization policies (the page carries
|
||||||
|
/// <c>[Authorize(OperationalAudit)]</c> and an in-page <c>AuthorizeView</c> for
|
||||||
|
/// the Export button), <see cref="ISiteRepository"/> (AuditFilterBar),
|
||||||
|
/// <see cref="IAuditLogQueryService"/> (AuditResultsGrid), and the new
|
||||||
|
/// <see cref="IKpiHistoryQueryService"/> (the Trends panel). The page is wrapped
|
||||||
|
/// in a <see cref="CascadingAuthenticationState"/> the router would supply in
|
||||||
|
/// production.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogPageTrendTests : BunitContext
|
||||||
|
{
|
||||||
|
private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
public AuditLogPageTrendTests()
|
||||||
|
{
|
||||||
|
// AuditResultsGrid's OnAfterRenderAsync wires its column resize/reorder UX
|
||||||
|
// through audit-grid.js. Loose mode lets those unconfigured JS calls no-op
|
||||||
|
// so these trend tests need not configure browser interop.
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
||||||
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<KpiSeriesPoint> ThreePoints() => new[]
|
||||||
|
{
|
||||||
|
new KpiSeriesPoint(Base, 1.0),
|
||||||
|
new KpiSeriesPoint(Base.AddMinutes(20), 4.0),
|
||||||
|
new KpiSeriesPoint(Base.AddMinutes(40), 2.0),
|
||||||
|
};
|
||||||
|
|
||||||
|
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(IKpiHistoryQueryService kpiHistory)
|
||||||
|
{
|
||||||
|
var user = BuildPrincipal("Administrator");
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||||
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||||
|
|
||||||
|
// The page hosts AuditFilterBar + AuditResultsGrid (Bundle B) and the
|
||||||
|
// K15 Trends panel — register a stand-in for each injected dependency.
|
||||||
|
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
||||||
|
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
|
||||||
|
Services.AddSingleton(kpiHistory);
|
||||||
|
|
||||||
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||||
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<AuditLogPage>(0);
|
||||||
|
builder.CloseComponent();
|
||||||
|
})));
|
||||||
|
|
||||||
|
return host.FindComponent<AuditLogPage>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrendsPanel_WithSeries_RendersChartsWithPolyline()
|
||||||
|
{
|
||||||
|
var kpi = Substitute.For<IKpiHistoryQueryService>();
|
||||||
|
kpi.GetSeriesAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult(ThreePoints()));
|
||||||
|
|
||||||
|
var cut = RenderAuditLogPage(kpi);
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
// The panel container is present...
|
||||||
|
Assert.Contains("data-test=\"audit-trends\"", cut.Markup);
|
||||||
|
// ...and at least one chart rendered an actual polyline series.
|
||||||
|
Assert.Contains("kpi-trend-", cut.Markup);
|
||||||
|
Assert.Contains("<polyline", cut.Markup);
|
||||||
|
// The audit query UI still rendered alongside the trends panel.
|
||||||
|
Assert.Contains("Audit Log", cut.Markup);
|
||||||
|
});
|
||||||
|
|
||||||
|
// All three AuditLog global metrics were queried (one chart each).
|
||||||
|
kpi.Received().GetSeriesAsync(
|
||||||
|
KpiSources.AuditLog, "totalEventsLastHour", KpiScopes.Global, null,
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||||
|
kpi.Received().GetSeriesAsync(
|
||||||
|
KpiSources.AuditLog, "errorEventsLastHour", KpiScopes.Global, null,
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||||
|
kpi.Received().GetSeriesAsync(
|
||||||
|
KpiSources.AuditLog, "backlogTotal", KpiScopes.Global, null,
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrendsPanel_WhenQueryServiceThrows_PageStillRenders()
|
||||||
|
{
|
||||||
|
// Best-effort contract: a failing KPI-history query degrades the charts to
|
||||||
|
// the unavailable placeholder but must NOT throw out of the page render.
|
||||||
|
var kpi = Substitute.For<IKpiHistoryQueryService>();
|
||||||
|
kpi.GetSeriesAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||||
|
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ => throw new InvalidOperationException("kpi down"));
|
||||||
|
|
||||||
|
var cut = RenderAuditLogPage(kpi);
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
// The page rendered — heading + trends panel container both present —
|
||||||
|
// even though every series fetch threw.
|
||||||
|
Assert.Contains("Audit Log", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"audit-trends\"", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user