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