553db2bce3
Replaces PreMatch/Live placeholder pages with a shared EventListShell
(filter chips, date range, sortable virtualized-friendly table, debounced
search, live auto-refresh with odds-movement indicators) and adds a new
/events/{eventCode} detail page (asymmetric header lockup, dynamic
Match/Period tabs, Plotly.Blazor odds-over-time chart with accessible
data-table fallback, snapshot history, Excel export modal).
New primitives matching Phase 5's editorial-quant system:
- SportIcon: inline SVGs per sport (basketball=6, football=11,
tennis=22723, hockey=43658, generic fallback)
- OddsCell: tabular mono with ▲/▼/— delta + flash on change
(prefers-reduced-motion honored)
- OddsTimeline: Plotly.Blazor wrapper with theme-aware colors and
<details>/<summary> data-table screen-reader fallback
- ExportDialog: From/To pickers + ExportKind radio + Esc/Enter
keyboard, surfaces use-case errors inline
- EventListShell: shared section shell for PreMatch/Live cadence
State + service split keeps the RCL host-agnostic:
- IEventBrowsingService / EventBrowsingService — wraps repos, returns
view-model records (EventListItem, EventDetail, EventScopeBoard,
BetRow, OddsTimelinePoint, SnapshotHistoryEntry); pages never see
EF or domain entities directly.
- EventBrowsingState — singleton (per-circuit in BlazorWebView) holding
immutable PageFilter records for PreMatch and Live.
Plotly.Blazor 5.4.1 added (latest .NET 8 line; 7.x has breaking changes).
+59 RU/EN localization keys following the Phase 5 dot-segmented convention.
Tests: +26 bUnit tests (PreMatch/Live/Detail pages, OddsCell/SportIcon/
ExportDialog components, EventBrowsingState). Total 228/228 passing
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline 202).
Build clean (0/0).
PLAN.md: P2/P3/P5 top-level checkboxes ticked; P6 row marked Done.
97 lines
3.6 KiB
C#
97 lines
3.6 KiB
C#
using Bunit;
|
|
using Marathon.Application.Abstractions;
|
|
using Marathon.Application.Storage;
|
|
using Marathon.Application.UseCases;
|
|
using AppDateRange = Marathon.Application.Storage.DateRange;
|
|
using Marathon.UI.Components;
|
|
using Marathon.UI.Tests.Support;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using MudBlazor;
|
|
using NSubstitute;
|
|
|
|
namespace Marathon.UI.Tests.Components;
|
|
|
|
public sealed class ExportDialogTests : MarathonTestContext
|
|
{
|
|
private readonly IExcelExporter _exporter = Substitute.For<IExcelExporter>();
|
|
|
|
public ExportDialogTests()
|
|
{
|
|
Services.AddSingleton(_exporter);
|
|
Services.AddSingleton(Options.Create(new StorageOptions
|
|
{
|
|
ExportDirectory = Path.Combine(Path.GetTempPath(), "marathon-tests"),
|
|
}));
|
|
Services.AddSingleton(new ExportToExcelUseCase(
|
|
_exporter,
|
|
Options.Create(new StorageOptions
|
|
{
|
|
ExportDirectory = Path.Combine(Path.GetTempPath(), "marathon-tests"),
|
|
}),
|
|
NullLogger<ExportToExcelUseCase>.Instance));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submit_calls_export_use_case()
|
|
{
|
|
_exporter.ExportAsync(Arg.Any<AppDateRange>(), Arg.Any<ExportKind>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult("C:/temp/Marathon_2026-05-04_to_2026-05-06.xlsx"));
|
|
|
|
var host = RenderComponent<DialogHost>();
|
|
IDialogReference? reference = null;
|
|
await host.InvokeAsync(async () =>
|
|
{
|
|
var svc = Services.GetRequiredService<IDialogService>();
|
|
reference = await svc.ShowAsync<ExportDialog>("Export");
|
|
});
|
|
|
|
reference.Should().NotBeNull();
|
|
|
|
// Click the Export button (the second action button — first is Cancel).
|
|
host.WaitForAssertion(() => host.FindAll(".mud-button").Count.Should().BeGreaterThanOrEqualTo(2));
|
|
var actionButtons = host.FindAll(".mud-dialog-actions .mud-button");
|
|
var submit = actionButtons.Last();
|
|
|
|
await host.InvokeAsync(() => submit.Click());
|
|
|
|
await _exporter.Received(1).ExportAsync(
|
|
Arg.Any<AppDateRange>(),
|
|
Arg.Any<ExportKind>(),
|
|
Arg.Any<string>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_does_not_call_export()
|
|
{
|
|
var host = RenderComponent<DialogHost>();
|
|
await host.InvokeAsync(async () =>
|
|
{
|
|
var svc = Services.GetRequiredService<IDialogService>();
|
|
await svc.ShowAsync<ExportDialog>("Export");
|
|
});
|
|
|
|
host.WaitForAssertion(() => host.FindAll(".mud-dialog-actions .mud-button").Count.Should().BeGreaterThanOrEqualTo(2));
|
|
var cancelBtn = host.FindAll(".mud-dialog-actions .mud-button").First();
|
|
await host.InvokeAsync(() => cancelBtn.Click());
|
|
|
|
await _exporter.DidNotReceiveWithAnyArgs().ExportAsync(default!, default, default!, default);
|
|
}
|
|
|
|
/// <summary>Renders the MudDialogProvider so dialogs can be hosted in tests.</summary>
|
|
private sealed class DialogHost : Microsoft.AspNetCore.Components.ComponentBase
|
|
{
|
|
protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder)
|
|
{
|
|
builder.OpenComponent<MudDialogProvider>(0);
|
|
builder.CloseComponent();
|
|
builder.OpenComponent<MudPopoverProvider>(1);
|
|
builder.CloseComponent();
|
|
builder.OpenComponent<MudSnackbarProvider>(2);
|
|
builder.CloseComponent();
|
|
}
|
|
}
|
|
}
|