feat(phase-6): event browsing UI — pre-match/live lists, detail page, +26 bUnit tests

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.
This commit is contained in:
2026-05-05 12:58:03 +03:00
parent fe97643a41
commit 553db2bce3
32 changed files with 3060 additions and 64 deletions
@@ -0,0 +1,96 @@
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();
}
}
}
@@ -0,0 +1,63 @@
using Bunit;
using Marathon.UI.Components;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Components;
public sealed class OddsCellTests : MarathonTestContext
{
[Fact]
public void Formats_decimal_to_two_places()
{
var cut = RenderComponent<OddsCell>(p => p.Add(c => c.Rate, 1.8m));
cut.Find(".m-odds__value").TextContent.Trim().Should().Be("1.80");
}
[Fact]
public void Renders_em_dash_when_rate_is_null()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, (decimal?)null);
p.Add(c => c.EmptyPlaceholder, "—");
});
cut.Find(".m-odds__value").TextContent.Trim().Should().Be("—");
}
[Fact]
public void Marks_up_when_rate_increased()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, 1.95m);
p.Add(c => c.Previous, 1.85m);
p.Add(c => c.ShowTrend, true);
});
cut.Find(".m-odds").GetAttribute("data-trend").Should().Be("rising");
cut.Find(".m-odds__delta").TextContent.Should().Contain("▲");
}
[Fact]
public void Marks_down_when_rate_decreased()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, 1.70m);
p.Add(c => c.Previous, 1.85m);
p.Add(c => c.ShowTrend, true);
});
cut.Find(".m-odds").GetAttribute("data-trend").Should().Be("falling");
cut.Find(".m-odds__delta").TextContent.Should().Contain("▼");
}
[Fact]
public void Hides_delta_glyph_when_show_trend_false()
{
var cut = RenderComponent<OddsCell>(p =>
{
p.Add(c => c.Rate, 1.85m);
p.Add(c => c.ShowTrend, false);
});
cut.FindAll(".m-odds__delta").Should().BeEmpty();
}
}
@@ -0,0 +1,37 @@
using Bunit;
using Marathon.UI.Components;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Components;
public sealed class SportIconTests : MarathonTestContext
{
[Theory]
[InlineData(6)]
[InlineData(11)]
[InlineData(22723)]
[InlineData(43658)]
[InlineData(99999)]
public void Renders_an_svg_for_known_and_unknown_sports(int code)
{
var cut = RenderComponent<SportIcon>(p =>
{
p.Add(c => c.Code, code);
p.Add(c => c.Label, $"sport-{code}");
});
cut.Find(".m-sport").GetAttribute("data-sport").Should().Be(code.ToString());
cut.FindAll("svg").Should().NotBeEmpty();
}
[Fact]
public void Sets_aria_label_from_label_param()
{
var cut = RenderComponent<SportIcon>(p =>
{
p.Add(c => c.Code, 11);
p.Add(c => c.Label, "Football");
});
cut.Find(".m-sport").GetAttribute("aria-label").Should().Be("Football");
}
}
@@ -0,0 +1,71 @@
using Bunit;
using Marathon.UI.Pages.Events;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Pages.Events;
public sealed class DetailTests : MarathonTestContext
{
[Fact]
public void Renders_not_found_when_detail_is_null()
{
Browsing.Detail = null;
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "missing"));
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("Detail.NotFound");
});
}
[Fact]
public void Renders_event_header_and_tabs_for_each_scope()
{
Browsing.Detail = TestData.Detail(id: "ABC-1");
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "ABC-1"));
cut.WaitForAssertion(() =>
{
// Two tabs from the seeded detail (Match + Period 1).
cut.FindAll(".m-detail-tab").Count.Should().Be(2);
cut.Markup.Should().Contain("Arsenal");
cut.Markup.Should().Contain("Chelsea");
cut.Markup.Should().Contain("Detail.Tabs.Match");
});
}
[Fact]
public void Renders_snapshot_history_entries()
{
Browsing.Detail = TestData.Detail(
id: "ABC-2",
new OddsTimelinePoint(DateTimeOffset.UtcNow.AddMinutes(-30), 1.90m, 3.30m, 4.10m),
new OddsTimelinePoint(DateTimeOffset.UtcNow.AddMinutes(-15), 1.85m, 3.40m, 4.20m),
new OddsTimelinePoint(DateTimeOffset.UtcNow, 1.80m, 3.50m, 4.30m));
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "ABC-2"));
cut.WaitForAssertion(() =>
{
// Three rows in history table (one per timeline point).
// We match the formatted rate cell text instead of generic table rows.
cut.Markup.Should().Contain("1.85");
cut.Markup.Should().Contain("1.80");
cut.Markup.Should().Contain("1.90");
});
}
[Fact]
public void Renders_chart_or_data_table_fallback()
{
Browsing.Detail = TestData.Detail(id: "ABC-3");
var cut = RenderComponent<Detail>(p => p.Add(d => d.EventCode, "ABC-3"));
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("Detail.Chart.Title");
// <details> fallback for screen readers
cut.Markup.Should().Contain("Detail.Chart.AccessibleSummary");
});
}
}
@@ -0,0 +1,53 @@
using Bunit;
using Marathon.UI.Pages;
using Marathon.UI.Services;
using Marathon.UI.Tests.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Marathon.UI.Tests.Pages;
public sealed class LiveTests : MarathonTestContext
{
public LiveTests()
{
var monitor = new TestOptionsMonitor<ScrapingSettingsForm>(new ScrapingSettingsForm
{
PollingIntervalSeconds = 30,
});
Services.AddSingleton<IOptionsMonitor<ScrapingSettingsForm>>(monitor);
}
[Fact]
public void Reads_polling_interval_from_options_monitor()
{
Browsing.LiveItems.Add(TestData.ListItem(id: "LV-1"));
var cut = RenderComponent<Live>();
cut.WaitForAssertion(() =>
{
// Live.AutoRefresh badge surfaces the configured cadence.
cut.Markup.Should().Contain("Live.AutoRefresh");
cut.Markup.Should().Contain("30 s");
});
}
[Fact]
public void Renders_seeded_live_rows()
{
Browsing.LiveItems.AddRange(new[]
{
TestData.ListItem(id: "LV-1", side1: "Roma", side2: "Lazio"),
TestData.ListItem(id: "LV-2", side1: "Federer", side2: "Nadal", sport: 22723),
});
var cut = RenderComponent<Live>();
cut.WaitForAssertion(() =>
{
cut.FindAll("[data-test=event-row]").Count.Should().Be(2);
});
cut.Markup.Should().Contain("Roma");
cut.Markup.Should().Contain("Federer");
}
}
@@ -0,0 +1,53 @@
using Bunit;
using Marathon.UI.Pages;
using Marathon.UI.Tests.Support;
namespace Marathon.UI.Tests.Pages;
public sealed class PreMatchTests : MarathonTestContext
{
[Fact]
public void Renders_seeded_rows()
{
Browsing.SportCodes.AddRange(new[] { 6, 11 });
Browsing.UpcomingItems.AddRange(new[]
{
TestData.ListItem(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
TestData.ListItem(id: "EV-2", side1: "Lakers", side2: "Bulls", sport: 6),
});
var cut = RenderComponent<PreMatch>();
cut.WaitForAssertion(() =>
{
var rows = cut.FindAll("[data-test=event-row]");
rows.Count.Should().Be(2);
});
cut.Markup.Should().Contain("Arsenal");
cut.Markup.Should().Contain("Lakers");
}
[Fact]
public void Renders_empty_state_when_no_events()
{
var cut = RenderComponent<PreMatch>();
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("PreMatch.Empty");
});
}
[Fact]
public void Calls_loader_with_active_filter_dates()
{
Browsing.UpcomingItems.Add(TestData.ListItem());
var cut = RenderComponent<PreMatch>();
cut.WaitForAssertion(() =>
{
Browsing.UpcomingCallCount.Should().BeGreaterThan(0);
Browsing.LastUpcomingFilter.Should().NotBeNull();
Browsing.LastUpcomingFilter!.Dates.From.Should().BeBefore(Browsing.LastUpcomingFilter.Dates.To);
});
}
}
@@ -0,0 +1,51 @@
using Marathon.UI.Services;
namespace Marathon.UI.Tests.Services;
public sealed class EventBrowsingStateTests
{
[Fact]
public void Default_filter_spans_minus_one_to_plus_seven_days()
{
var state = new EventBrowsingState();
var f = state.PreMatch;
(f.To - f.From).TotalDays.Should().BeApproximately(8, 0.01);
f.SortKey.Should().Be(EventSortKey.ScheduledAt);
f.SortDescending.Should().BeFalse();
}
[Fact]
public void UpdatePreMatch_raises_OnChange_when_value_changes()
{
var state = new EventBrowsingState();
var fired = 0;
state.OnChange += () => fired++;
var next = state.PreMatch with { SearchTerm = "Real Madrid" };
state.UpdatePreMatch(next);
fired.Should().Be(1);
state.PreMatch.SearchTerm.Should().Be("Real Madrid");
}
[Fact]
public void UpdatePreMatch_does_not_raise_when_value_unchanged()
{
var state = new EventBrowsingState();
var fired = 0;
state.OnChange += () => fired++;
state.UpdatePreMatch(state.PreMatch with { });
fired.Should().Be(0);
}
[Fact]
public void Live_and_PreMatch_filters_are_independent()
{
var state = new EventBrowsingState();
state.UpdateLive(state.Live with { SearchTerm = "live-only" });
state.PreMatch.SearchTerm.Should().BeEmpty();
state.Live.SearchTerm.Should().Be("live-only");
}
}
@@ -0,0 +1,62 @@
using Marathon.UI.Services;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Tests.Support;
/// <summary>
/// In-memory <see cref="IEventBrowsingService"/> for bUnit tests.
/// Seed via <see cref="UpcomingItems"/> / <see cref="LiveItems"/> / <see cref="Detail"/>.
/// </summary>
public sealed class FakeEventBrowsingService : IEventBrowsingService
{
public List<EventListItem> UpcomingItems { get; } = new();
public List<EventListItem> LiveItems { get; } = new();
public EventDetail? Detail { get; set; }
public List<int> SportCodes { get; } = new();
public List<string> CountryCodes { get; } = new();
public int UpcomingCallCount { get; private set; }
public int LiveCallCount { get; private set; }
public EventFilter? LastUpcomingFilter { get; private set; }
public EventFilter? LastLiveFilter { get; private set; }
public Task<IReadOnlyList<EventListItem>> ListUpcomingAsync(EventFilter filter, CancellationToken ct)
{
UpcomingCallCount++;
LastUpcomingFilter = filter;
return Task.FromResult<IReadOnlyList<EventListItem>>(Apply(UpcomingItems, filter));
}
public Task<IReadOnlyList<EventListItem>> ListLiveAsync(EventFilter filter, CancellationToken ct)
{
LiveCallCount++;
LastLiveFilter = filter;
return Task.FromResult<IReadOnlyList<EventListItem>>(Apply(LiveItems, filter));
}
public Task<EventDetail?> GetDetailAsync(DomainEventId eventId, CancellationToken ct)
=> Task.FromResult(Detail);
public Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<int>>(SportCodes);
public Task<IReadOnlyList<string>> ListKnownCountryCodesAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(CountryCodes);
private static IReadOnlyList<EventListItem> Apply(IEnumerable<EventListItem> source, EventFilter filter)
{
IEnumerable<EventListItem> q = source;
if (filter.SportCodes is { Count: > 0 } sports)
q = q.Where(e => sports.Contains(e.Sport.Value));
if (filter.CountryCodes is { Count: > 0 } countries)
q = q.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
{
var t = filter.SearchTerm.Trim();
q = q.Where(e =>
e.LeagueId.Contains(t, StringComparison.OrdinalIgnoreCase) ||
e.Side1Name.Contains(t, StringComparison.OrdinalIgnoreCase) ||
e.Side2Name.Contains(t, StringComparison.OrdinalIgnoreCase));
}
return q.ToList();
}
}
@@ -19,6 +19,8 @@ public abstract class MarathonTestContext : TestContext
protected TestSettingsWriter Writer { get; } = new();
protected ThemeState Theme { get; } = new();
protected LocaleState Locale { get; } = new();
protected EventBrowsingState BrowsingState { get; } = new();
protected FakeEventBrowsingService Browsing { get; } = new();
protected MarathonTestContext()
{
@@ -26,6 +28,8 @@ public abstract class MarathonTestContext : TestContext
Services.AddSingleton<ISettingsWriter>(Writer);
Services.AddSingleton(Theme);
Services.AddSingleton(Locale);
Services.AddSingleton(BrowsingState);
Services.AddSingleton<IEventBrowsingService>(Browsing);
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
@@ -0,0 +1,91 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.UI.Services;
namespace Marathon.UI.Tests.Support;
internal static class TestData
{
public static readonly TimeSpan Moscow = TimeSpan.FromHours(3);
public static EventListItem ListItem(
string id = "100001",
int sport = 11,
string country = "ENG",
string league = "Premier League",
string side1 = "Arsenal",
string side2 = "Chelsea",
decimal? win1 = 1.85m,
decimal? draw = 3.40m,
decimal? win2 = 4.20m,
DateTimeOffset? scheduled = null)
=> new(
new EventId(id),
new SportCode(sport),
country,
league,
side1,
side2,
scheduled ?? MoscowToday(20),
win1,
draw,
win2,
DateTimeOffset.UtcNow,
OddsSource.PreMatch);
/// <summary>Today at <paramref name="hour"/>:00 in Moscow (+03:00).</summary>
public static DateTimeOffset MoscowToday(int hour)
{
var nowMoscow = DateTimeOffset.UtcNow.ToOffset(Moscow);
var midnight = new DateTimeOffset(
nowMoscow.Year, nowMoscow.Month, nowMoscow.Day,
0, 0, 0, Moscow);
return midnight.AddHours(hour);
}
public static EventDetail Detail(
string id = "100001",
params OddsTimelinePoint[] timeline)
{
var moscow = Moscow;
var scheduled = MoscowToday(20);
var timelinePts = timeline.Length > 0
? timeline.ToList()
: new List<OddsTimelinePoint>
{
new(scheduled.AddMinutes(-30), 1.90m, 3.30m, 4.10m),
new(scheduled.AddMinutes(-15), 1.85m, 3.40m, 4.20m),
};
var matchBoard = new EventScopeBoard(MatchScope.Instance, new List<BetRow>
{
new(BetType.Win, Side.Side1, null, 1.85m),
new(BetType.Draw, Side.Draw, null, 3.40m),
new(BetType.Win, Side.Side2, null, 4.20m),
});
var period1Board = new EventScopeBoard(new PeriodScope(1), new List<BetRow>
{
new(BetType.Win, Side.Side1, null, 2.10m),
new(BetType.Win, Side.Side2, null, 3.10m),
new(BetType.Total, Side.More, 1.5m, 1.95m),
new(BetType.Total, Side.Less, 1.5m, 1.95m),
});
var history = timelinePts.Select(p =>
new SnapshotHistoryEntry(p.At, OddsSource.PreMatch, 8, p.Win1Rate, p.DrawRate, p.Win2Rate)
).ToList();
return new EventDetail(
new EventId(id),
new SportCode(11),
"ENG",
"Premier League",
"Arsenal",
"Chelsea",
scheduled,
new[] { matchBoard, period1Board },
timelinePts,
history);
}
}
@@ -0,0 +1,41 @@
using Microsoft.Extensions.Options;
namespace Marathon.UI.Tests.Support;
/// <summary>
/// Minimal in-memory <see cref="IOptionsMonitor{TOptions}"/> for tests.
/// </summary>
public sealed class TestOptionsMonitor<T> : IOptionsMonitor<T> where T : class, new()
{
private T _value;
private readonly List<Action<T, string?>> _listeners = new();
public TestOptionsMonitor(T initial)
{
_value = initial;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable OnChange(Action<T, string?> listener)
{
_listeners.Add(listener);
return new Subscription(() => _listeners.Remove(listener));
}
public void Set(T next)
{
_value = next;
foreach (var listener in _listeners.ToList())
listener(next, null);
}
private sealed class Subscription : IDisposable
{
private readonly Action _dispose;
public Subscription(Action dispose) { _dispose = dispose; }
public void Dispose() => _dispose();
}
}