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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user