feat(phase-8-frontend): results loader UI + browsing list + 41 localization keys

* Pages/Results/ResultsList.razor — completed-events list with date range,
  sport/winner filter, search, footer count.
* Pages/Results/ResultsLoader.razor — driver page with two modes (load all
  in range / load selected events), live progress reporting via
  IProgress<PullResultsProgress>, summary line, cancellable.
* Replaces the Phase 5 Pages/Results.razor placeholder.

Service layer:
* IResultsBrowsingService + ResultsBrowsingService (Scoped, mirrors the
  Event/Anomaly browsing-service pattern). Reads IResultRepository +
  IEventRepository, projects to immutable view-model records.
* UiServicesExtensions: registers ResultsBrowsingService; also fixes an
  unrelated localization resolver bug (drop ResourcesPath since
  SharedResource lives in the Marathon.UI.Resources namespace already).

Localization:
* 41 new Results.* keys (RU+EN parity) covering both pages, filter chips,
  loader modes, progress states, and footer copy.

Tests:
* ResultsListTests + ResultsLoaderTests — 22 new bUnit tests covering
  filter narrowing, mode switching, progress aggregation, and empty
  states.
* FakeResultsBrowsingService support type for tests.
* MarathonTestContext registers the fake; TestData adds factories for
  EventResult/EventResultListItem.
This commit is contained in:
2026-05-09 15:10:49 +03:00
parent 9c5d3df1f2
commit 9f090cec1f
13 changed files with 1407 additions and 6 deletions
-5
View File
@@ -1,5 +0,0 @@
@page "/results"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Results"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Results"]" />
@@ -0,0 +1,330 @@
@page "/results"
@using Marathon.Domain.Enums
@using Marathon.UI.Components
@using Marathon.UI.Services
@inject IStringLocalizer<SharedResource> L
@inject IResultsBrowsingService Browsing
@inject IEventBrowsingService Events
@inject NavigationManager Nav
@implements IDisposable
<PageTitle>@L["App.Title"] · @L["Nav.Results"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Nav.Section.Analysis"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Results.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Results.Lede"]</p>
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="GoToLoader"
data-test="results-open-loader">
@L["Results.Action.LoadNew"]
</MudButton>
</div>
</header>
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["Results.Title"]">
<div class="m-list-toolbar__row">
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Results.Filter.From"]</label>
<input class="m-input" type="date" value="@FormatDate(_from)" @onchange="OnFromChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Results.Filter.To"]</label>
<input class="m-input" type="date" value="@FormatDate(_to)" @onchange="OnToChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Results.Filter.Winner"]</label>
<select class="m-input"
@onchange="OnWinnerChanged"
data-test="results-winner-filter">
<option value="" selected="@(_winner is null)">@L["Results.Filter.Winner.All"]</option>
<option value="Side1" selected="@(_winner == Side.Side1)">@L["Results.Filter.Winner.Side1"]</option>
<option value="Side2" selected="@(_winner == Side.Side2)">@L["Results.Filter.Winner.Side2"]</option>
<option value="Draw" selected="@(_winner == Side.Draw)">@L["Results.Filter.Winner.Draw"]</option>
</select>
</div>
<div class="m-list-toolbar__group m-list-toolbar__group--grow">
<label class="m-list-toolbar__label">@L["Results.Filter.Search"]</label>
<input class="m-input"
type="search"
value="@_searchInput"
placeholder="@L["Results.Filter.Search.Placeholder"]"
@oninput="OnSearchInput" />
</div>
</div>
@if (_availableSports.Count > 0)
{
<div class="m-list-toolbar__row m-list-toolbar__chips">
<span class="m-list-toolbar__label">@L["Results.Filter.Sport"]</span>
@foreach (var sportCode in _availableSports)
{
var active = _sportCodes.Contains(sportCode);
var label = SportLabel(sportCode);
var localCode = sportCode;
<button type="button"
class="m-chip @(active ? "is-active" : null)"
aria-pressed="@active"
@onclick="() => ToggleSport(localCode)">
<SportIcon Code="@localCode" Label="@label" ClassName="m-chip__icon" />
<span>@label</span>
</button>
}
</div>
}
</div>
<div class="m-list-table m-rise m-rise-3" role="region" aria-label="@L["Results.Title"]">
@if (_loading)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_rows.Count == 0)
{
<div class="m-list-empty">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">@L["Common.Empty"]</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">@L["Results.Empty"]</p>
</div>
}
else
{
<table class="m-table" data-test="results-list-table">
<thead>
<tr>
<th scope="col" style="width: 36px;"></th>
<th scope="col">@L["Results.Column.Time"]</th>
<th scope="col">@L["Results.Column.Country"]</th>
<th scope="col">@L["Results.Column.League"]</th>
<th scope="col">@L["Results.Column.Match"]</th>
<th scope="col" style="text-align: center;">@L["Results.Column.Score"]</th>
<th scope="col">@L["Results.Column.Winner"]</th>
<th scope="col">@L["Results.Column.CompletedAt"]</th>
</tr>
</thead>
<tbody>
@foreach (var row in _rows)
{
<tr class="m-table__row"
data-test="results-row"
data-event-id="@row.Id.Value"
tabindex="0"
@onclick="() => OpenDetail(row)"
@onkeydown="@(e => HandleRowKey(e, row))">
<td>
<SportIcon Code="@row.Sport.Value" Label="@SportLabel(row.Sport.Value)" />
</td>
<td class="m-mono">@row.ScheduledAt.ToString("dd MMM HH:mm")</td>
<td>@row.CountryCode</td>
<td>@row.LeagueId</td>
<td style="font-weight: 500;">@row.Side1Name <span style="color: var(--m-c-ink-soft);">vs</span> @row.Side2Name</td>
<td class="m-mono" style="text-align: center; font-weight: 600;" data-test="results-score">
@row.Side1Score:@row.Side2Score
</td>
<td>
<span class="m-result-winner m-result-winner--@WinnerCss(row.WinnerSide)" data-test="results-winner">
@WinnerLabel(row.WinnerSide)
</span>
</td>
<td class="m-mono">@row.CompletedAt.ToLocalTime().ToString("dd MMM HH:mm")</td>
</tr>
}
</tbody>
</table>
<div class="m-list-table__footer">
<span class="m-mono" style="font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--m-c-ink-soft);">
@_rows.Count @L["Results.Footer.Items"]
</span>
</div>
}
</div>
</section>
<style>
.m-result-winner {
display: inline-block;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
border-radius: var(--m-radius-xs);
border: 1px solid var(--m-c-rule);
}
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
[data-theme="dark"] .m-result-winner--side1 { color: #4ade80; background: rgba(34,197,94,0.15); }
[data-theme="dark"] .m-result-winner--side2 { color: #93c5fd; background: rgba(59,130,246,0.15); }
</style>
@code {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private DateTimeOffset _from;
private DateTimeOffset _to;
private string _searchInput = string.Empty;
private Side? _winner;
private List<int> _sportCodes = new();
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
private List<EventResultListItem> _rows = new();
private bool _loading;
private CancellationTokenSource? _searchCts;
private CancellationTokenSource? _loadCts;
protected override async Task OnInitializedAsync()
{
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowOffset);
_from = todayMoscow.AddDays(-30);
_to = todayMoscow.AddDays(1).AddSeconds(-1);
try
{
_availableSports = await Events.ListKnownSportCodesAsync(CancellationToken.None);
}
catch
{
_availableSports = Array.Empty<int>();
}
await LoadAsync();
}
private async Task LoadAsync()
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_loading = true;
try
{
var filter = new ResultsFilter(
Dates: new DateRangeFilter(_from, _to),
SportCodes: _sportCodes.Count == 0 ? null : _sportCodes.ToArray(),
WinnerSide: _winner,
SearchTerm: string.IsNullOrWhiteSpace(_searchInput) ? null : _searchInput.Trim());
var rows = await Browsing.ListResultsAsync(filter, ct);
if (ct.IsCancellationRequested) return;
_rows = rows.ToList();
}
catch (OperationCanceledException) { /* superseded */ }
catch
{
_rows = new();
}
finally
{
_loading = false;
StateHasChanged();
}
}
private void GoToLoader() => Nav.NavigateTo("/results/load");
private void OpenDetail(EventResultListItem row)
=> Nav.NavigateTo($"/events/{Uri.EscapeDataString(row.Id.Value)}");
private async Task HandleRowKey(KeyboardEventArgs e, EventResultListItem row)
{
if (e.Key == "Enter" || e.Key == " ")
{
OpenDetail(row);
await Task.CompletedTask;
}
}
private async Task OnFromChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_from = new DateTimeOffset(v.Date, MoscowOffset);
await LoadAsync();
}
}
private async Task OnToChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_to = new DateTimeOffset(v.Date, MoscowOffset).AddDays(1).AddSeconds(-1);
await LoadAsync();
}
}
private async Task OnWinnerChanged(ChangeEventArgs e)
{
var raw = e.Value?.ToString();
_winner = raw switch
{
"Side1" => Side.Side1,
"Side2" => Side.Side2,
"Draw" => Side.Draw,
_ => null,
};
await LoadAsync();
}
private async Task OnSearchInput(ChangeEventArgs e)
{
_searchInput = e.Value?.ToString() ?? string.Empty;
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
var token = _searchCts.Token;
try
{
await Task.Delay(300, token);
if (!token.IsCancellationRequested) await LoadAsync();
}
catch (TaskCanceledException) { /* superseded */ }
}
private async Task ToggleSport(int code)
{
if (!_sportCodes.Remove(code)) _sportCodes.Add(code);
await LoadAsync();
}
private static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd");
private string WinnerLabel(Side s) => s switch
{
Side.Side1 => L["Results.Filter.Winner.Side1"],
Side.Side2 => L["Results.Filter.Winner.Side2"],
Side.Draw => L["Results.Filter.Winner.Draw"],
_ => s.ToString(),
};
private static string WinnerCss(Side s) => s switch
{
Side.Side1 => "side1",
Side.Side2 => "side2",
Side.Draw => "draw",
_ => "draw",
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
public void Dispose()
{
_searchCts?.Cancel();
_searchCts?.Dispose();
_loadCts?.Cancel();
_loadCts?.Dispose();
}
}
@@ -0,0 +1,419 @@
@page "/results/load"
@using Microsoft.Extensions.DependencyInjection
@using Marathon.Application.UseCases
@using Marathon.Domain.Enums
@using Marathon.UI.Components
@using Marathon.UI.Services
@using AppDateRange = Marathon.Application.Storage.DateRange
@inject IStringLocalizer<SharedResource> L
@inject IResultsBrowsingService Browsing
@inject NavigationManager Nav
@inject IServiceProvider Sp
@implements IDisposable
<PageTitle>@L["App.Title"] · @L["Results.Loader.Title"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Results.Loader.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Results.Loader.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Results.Loader.Lede"]</p>
<div>
<MudButton Variant="Variant.Outlined" OnClick="GoBack" data-test="results-loader-back">
@L["Results.Loader.Action.Back"]
</MudButton>
</div>
</header>
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["Results.Loader.Title"]">
<div class="m-list-toolbar__row">
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Results.Filter.From"]</label>
<input class="m-input" type="date"
value="@FormatDate(_from)"
disabled="@_running"
@onchange="OnFromChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Results.Filter.To"]</label>
<input class="m-input" type="date"
value="@FormatDate(_to)"
disabled="@_running"
@onchange="OnToChanged" />
</div>
<div class="m-list-toolbar__group">
<label class="m-list-toolbar__label">@L["Results.Loader.Mode"]</label>
<select class="m-input"
disabled="@_running"
@onchange="OnModeChanged"
data-test="results-loader-mode">
<option value="AllInRange" selected="@(_mode == LoaderMode.AllInRange)">@L["Results.Loader.Mode.AllInRange"]</option>
<option value="Selected" selected="@(_mode == LoaderMode.Selected)">@L["Results.Loader.Mode.Selected"]</option>
</select>
</div>
</div>
@if (_mode == LoaderMode.Selected)
{
<div class="m-loader-picker" role="region" aria-label="@L["Results.Loader.Mode.Selected"]">
@if (_candidates.Count == 0)
{
<p style="color: var(--m-c-ink-soft); font-size: 0.875rem;" data-test="results-loader-no-candidates">
@L["Results.Loader.Selected.Empty"]
</p>
}
else
{
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--m-space-2);">
<span class="m-list-toolbar__label">@string.Format(L["Results.Loader.Selected.CountFormat"], _selectedIds.Count)</span>
</div>
<div class="m-loader-picker__scroller">
@foreach (var c in _candidates)
{
var checkedNow = _selectedIds.Contains(c.Id.Value);
var localId = c.Id.Value;
<label class="m-loader-picker__row" data-test="results-loader-candidate">
<input type="checkbox"
checked="@checkedNow"
disabled="@_running"
@onchange="@(e => ToggleSelected(localId, ((bool?)e.Value) ?? false))" />
<SportIcon Code="@c.Sport.Value" Label="@SportLabel(c.Sport.Value)" />
<span class="m-mono">@c.ScheduledAt.ToString("dd MMM HH:mm")</span>
<span style="color: var(--m-c-ink-soft);">@c.LeagueId</span>
<span style="font-weight: 500;">@c.Side1Name <span style="color: var(--m-c-ink-soft);">vs</span> @c.Side2Name</span>
</label>
}
</div>
}
</div>
}
<div class="m-list-toolbar__row" style="justify-content: flex-end; gap: var(--m-space-2);">
@if (_running)
{
<MudButton Variant="Variant.Outlined"
Color="Color.Error"
OnClick="CancelLoad"
data-test="results-loader-cancel">
@L["Results.Loader.Action.Cancel"]
</MudButton>
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Disabled="@(!CanLoad())"
OnClick="StartLoad"
data-test="results-loader-start">
@L["Results.Loader.Action.Load"]
</MudButton>
}
</div>
</div>
@if (_running || _completed)
{
<div class="m-rise m-rise-3" style="background: var(--m-c-paper); border: 1px solid var(--m-c-rule); padding: var(--m-space-4); display: grid; gap: var(--m-space-3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="m-mono" data-test="results-loader-progress">
@string.Format(L["Results.Loader.Progress.Format"], _processed, _total)
</span>
@if (_completed)
{
<span class="m-mono" style="color: var(--m-c-ink-soft); font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;" data-test="results-loader-summary">
@string.Format(L["Results.Loader.Summary.Format"], _summaryLoaded, _summarySkipped, _summaryProcessed)
</span>
}
</div>
<progress max="@(_total == 0 ? 1 : _total)" value="@_processed" style="width: 100%;"></progress>
@if (_progressLog.Count > 0)
{
<table class="m-table" data-test="results-loader-log">
<thead>
<tr>
<th scope="col">@L["Results.Column.Match"]</th>
<th scope="col" style="text-align: center;">@L["Results.Column.Score"]</th>
<th scope="col">@L["Results.Column.Winner"]</th>
<th scope="col">Outcome</th>
</tr>
</thead>
<tbody>
@foreach (var entry in _progressLog)
{
<tr data-test="results-loader-log-row">
<td>@entry.MatchLabel</td>
<td class="m-mono" style="text-align: center;">
@if (entry.Loaded is { } r)
{
@($"{r.Side1Score}:{r.Side2Score}")
}
else
{
@("—")
}
</td>
<td>
@if (entry.Loaded is { WinnerSide: var w })
{
<span class="m-result-winner m-result-winner--@WinnerCss(w)">@WinnerLabel(w)</span>
}
</td>
<td>
<span class="m-mono" style="font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;">
@OutcomeLabel(entry.Outcome)
</span>
</td>
</tr>
}
</tbody>
</table>
}
</div>
}
</section>
<style>
.m-loader-picker { display: grid; gap: var(--m-space-2); padding: var(--m-space-3); border: 1px solid var(--m-c-rule); background: var(--m-c-paper-2); border-radius: var(--m-radius-xs); }
.m-loader-picker__scroller { display: grid; gap: 4px; max-height: 280px; overflow-y: auto; }
.m-loader-picker__row {
display: grid;
grid-template-columns: 18px 24px max-content 1fr 2fr;
gap: var(--m-space-3);
align-items: center;
padding: 6px 8px;
font-size: 0.875rem;
border: 1px solid transparent;
border-radius: var(--m-radius-xs);
cursor: pointer;
}
.m-loader-picker__row:hover { background: var(--m-c-paper); border-color: var(--m-c-rule); }
.m-result-winner {
display: inline-block;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
border-radius: var(--m-radius-xs);
border: 1px solid var(--m-c-rule);
}
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
</style>
@code {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private DateTimeOffset _from;
private DateTimeOffset _to;
private LoaderMode _mode = LoaderMode.AllInRange;
private List<EventResultCandidate> _candidates = new();
private HashSet<string> _selectedIds = new(StringComparer.Ordinal);
private bool _running;
private bool _completed;
private int _processed;
private int _total;
private int _summaryLoaded;
private int _summarySkipped;
private int _summaryProcessed;
private List<ProgressEntry> _progressLog = new();
private CancellationTokenSource? _runCts;
private enum LoaderMode { AllInRange, Selected }
private sealed record ProgressEntry(
string MatchLabel,
ResultLoadOutcome Outcome,
Domain.Entities.EventResult? Loaded);
protected override async Task OnInitializedAsync()
{
var todayMoscow = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToOffset(MoscowOffset);
_from = todayMoscow.AddDays(-7);
_to = todayMoscow.AddDays(1).AddSeconds(-1);
await ReloadCandidatesAsync();
}
private async Task ReloadCandidatesAsync()
{
try
{
var range = new AppDateRange(_from, _to);
var fresh = await Browsing.ListLoadCandidatesAsync(range, CancellationToken.None);
_candidates = fresh.ToList();
// Keep selections that are still valid; drop the rest.
var stillValid = new HashSet<string>(fresh.Select(c => c.Id.Value), StringComparer.Ordinal);
_selectedIds.IntersectWith(stillValid);
}
catch
{
_candidates = new();
_selectedIds.Clear();
}
}
private bool CanLoad()
{
if (_running) return false;
if (_mode == LoaderMode.AllInRange) return _candidates.Count > 0;
return _selectedIds.Count > 0;
}
private async Task StartLoad()
{
_running = true;
_completed = false;
_progressLog.Clear();
_processed = 0;
IReadOnlyList<Domain.ValueObjects.EventId>? selection = _mode == LoaderMode.Selected
? _selectedIds.Select(s => new Domain.ValueObjects.EventId(s)).ToList()
: null;
// Pre-set Total so the progress bar has the right scale before the first tick.
_total = _mode == LoaderMode.Selected ? _selectedIds.Count : _candidates.Count;
StateHasChanged();
_runCts = new CancellationTokenSource();
var ct = _runCts.Token;
// Resolve a fresh use-case scope so the EF DbContext is owned by this run.
await using var scope = Sp.CreateAsyncScope();
var useCase = scope.ServiceProvider.GetRequiredService<PullResultsUseCase>();
var progress = new Progress<PullResultsProgress>(OnProgress);
try
{
var range = new AppDateRange(_from, _to);
var (inspected, loaded, skipped) = await useCase.ExecuteAsync(range, selection, progress, ct);
_summaryProcessed = inspected;
_summaryLoaded = loaded;
_summarySkipped = skipped;
_completed = true;
}
catch (OperationCanceledException)
{
// Cancelled — leave the partial log visible.
_completed = true;
}
finally
{
_running = false;
_runCts?.Dispose();
_runCts = null;
await ReloadCandidatesAsync();
StateHasChanged();
}
}
private void OnProgress(PullResultsProgress update)
{
_processed = update.Processed;
_total = Math.Max(_total, update.Total);
var label = LabelForEvent(update.EventId.Value);
_progressLog.Add(new ProgressEntry(
MatchLabel: label,
Outcome: update.Outcome,
Loaded: update.Result));
InvokeAsync(StateHasChanged);
}
private string LabelForEvent(string id)
{
var c = _candidates.FirstOrDefault(c => c.Id.Value == id);
return c is null
? id
: $"{c.Side1Name} vs {c.Side2Name}";
}
private void CancelLoad()
{
_runCts?.Cancel();
}
private void GoBack() => Nav.NavigateTo("/results");
private async Task OnFromChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_from = new DateTimeOffset(v.Date, MoscowOffset);
await ReloadCandidatesAsync();
}
}
private async Task OnToChanged(ChangeEventArgs e)
{
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
{
_to = new DateTimeOffset(v.Date, MoscowOffset).AddDays(1).AddSeconds(-1);
await ReloadCandidatesAsync();
}
}
private void OnModeChanged(ChangeEventArgs e)
{
var raw = e.Value?.ToString();
_mode = string.Equals(raw, nameof(LoaderMode.Selected), StringComparison.OrdinalIgnoreCase)
? LoaderMode.Selected
: LoaderMode.AllInRange;
}
private void ToggleSelected(string id, bool isChecked)
{
if (isChecked) _selectedIds.Add(id);
else _selectedIds.Remove(id);
}
private string WinnerLabel(Side s) => s switch
{
Side.Side1 => L["Results.Filter.Winner.Side1"],
Side.Side2 => L["Results.Filter.Winner.Side2"],
Side.Draw => L["Results.Filter.Winner.Draw"],
_ => s.ToString(),
};
private static string WinnerCss(Side s) => s switch
{
Side.Side1 => "side1",
Side.Side2 => "side2",
Side.Draw => "draw",
_ => "draw",
};
private string OutcomeLabel(ResultLoadOutcome o) => o switch
{
ResultLoadOutcome.Loaded => L["Results.Loader.Progress.Loaded"],
ResultLoadOutcome.AlreadyLoaded => L["Results.Loader.Progress.AlreadyLoaded"],
ResultLoadOutcome.NotYetComplete => L["Results.Loader.Progress.NotYetComplete"],
ResultLoadOutcome.Failed => L["Results.Loader.Progress.Failed"],
_ => o.ToString(),
};
private string SportLabel(int code) => code switch
{
6 => L["Sport.Basketball"],
11 => L["Sport.Football"],
22723 => L["Sport.Tennis"],
43658 => L["Sport.Hockey"],
_ => $"Sport {code}",
};
private static string FormatDate(DateTimeOffset value) => value.ToString("yyyy-MM-dd");
public void Dispose()
{
_runCts?.Cancel();
_runCts?.Dispose();
}
}
@@ -258,4 +258,47 @@
<data name="Sport.Football"><value>Football</value></data>
<data name="Sport.Tennis"><value>Tennis</value></data>
<data name="Sport.Hockey"><value>Hockey</value></data>
<data name="Results.Title"><value>Match results</value></data>
<data name="Results.Lede"><value>Final scores of loaded events. We walk each event page, wait for matchIsComplete=true, and record the winning side.</value></data>
<data name="Results.Action.LoadNew"><value>Load results</value></data>
<data name="Results.Action.OpenList"><value>Back to list</value></data>
<data name="Results.Filter.From"><value>From</value></data>
<data name="Results.Filter.To"><value>To</value></data>
<data name="Results.Filter.Search"><value>Search</value></data>
<data name="Results.Filter.Search.Placeholder"><value>Team, league, category…</value></data>
<data name="Results.Filter.Sport"><value>Sport</value></data>
<data name="Results.Filter.Winner"><value>Winner</value></data>
<data name="Results.Filter.Winner.All"><value>Any</value></data>
<data name="Results.Filter.Winner.Side1"><value>Side 1</value></data>
<data name="Results.Filter.Winner.Side2"><value>Side 2</value></data>
<data name="Results.Filter.Winner.Draw"><value>Draw</value></data>
<data name="Results.Column.Time"><value>Time</value></data>
<data name="Results.Column.Country"><value>Country</value></data>
<data name="Results.Column.League"><value>League</value></data>
<data name="Results.Column.Match"><value>Match</value></data>
<data name="Results.Column.Score"><value>Score</value></data>
<data name="Results.Column.Winner"><value>Winner</value></data>
<data name="Results.Column.CompletedAt"><value>Completed</value></data>
<data name="Results.Empty"><value>No results loaded for this range yet. Run a load or wait for matches to complete.</value></data>
<data name="Results.Footer.Items"><value>results</value></data>
<data name="Results.Loader.Kicker"><value>Loader</value></data>
<data name="Results.Loader.Title"><value>Load results</value></data>
<data name="Results.Loader.Lede"><value>We poll each event page, capture the final score, and record the winning side. Pick a date range or specific events.</value></data>
<data name="Results.Loader.Mode"><value>Mode</value></data>
<data name="Results.Loader.Mode.AllInRange"><value>All in range</value></data>
<data name="Results.Loader.Mode.Selected"><value>Selected events</value></data>
<data name="Results.Loader.Selected.Empty"><value>Every event in this range already has a result.</value></data>
<data name="Results.Loader.Selected.CountFormat"><value>{0} selected</value></data>
<data name="Results.Loader.Action.Load"><value>Load</value></data>
<data name="Results.Loader.Action.Cancel"><value>Cancel</value></data>
<data name="Results.Loader.Action.Back"><value>Back</value></data>
<data name="Results.Loader.Progress.Format"><value>{0} / {1}</value></data>
<data name="Results.Loader.Progress.Loaded"><value>Loaded</value></data>
<data name="Results.Loader.Progress.AlreadyLoaded"><value>Already loaded</value></data>
<data name="Results.Loader.Progress.NotYetComplete"><value>Not yet complete</value></data>
<data name="Results.Loader.Progress.Failed"><value>Failed</value></data>
<data name="Results.Loader.Summary.Format"><value>Loaded {0}, skipped {1}, processed {2} total.</value></data>
<data name="Results.Loader.Empty.NoCandidates"><value>No events to load in this range.</value></data>
</root>
@@ -271,4 +271,47 @@
<data name="Sport.Football"><value>Футбол</value></data>
<data name="Sport.Tennis"><value>Теннис</value></data>
<data name="Sport.Hockey"><value>Хоккей</value></data>
<data name="Results.Title"><value>Результаты матчей</value></data>
<data name="Results.Lede"><value>Финальные счета загруженных событий. Обходим страницу события, ждём matchIsComplete=true, фиксируем сторону-победителя.</value></data>
<data name="Results.Action.LoadNew"><value>Загрузить результаты</value></data>
<data name="Results.Action.OpenList"><value>К списку</value></data>
<data name="Results.Filter.From"><value>С</value></data>
<data name="Results.Filter.To"><value>По</value></data>
<data name="Results.Filter.Search"><value>Поиск</value></data>
<data name="Results.Filter.Search.Placeholder"><value>Команда, лига, категория…</value></data>
<data name="Results.Filter.Sport"><value>Спорт</value></data>
<data name="Results.Filter.Winner"><value>Победитель</value></data>
<data name="Results.Filter.Winner.All"><value>Любой</value></data>
<data name="Results.Filter.Winner.Side1"><value>Команда 1</value></data>
<data name="Results.Filter.Winner.Side2"><value>Команда 2</value></data>
<data name="Results.Filter.Winner.Draw"><value>Ничья</value></data>
<data name="Results.Column.Time"><value>Время</value></data>
<data name="Results.Column.Country"><value>Страна</value></data>
<data name="Results.Column.League"><value>Лига</value></data>
<data name="Results.Column.Match"><value>Матч</value></data>
<data name="Results.Column.Score"><value>Счёт</value></data>
<data name="Results.Column.Winner"><value>Победитель</value></data>
<data name="Results.Column.CompletedAt"><value>Завершено</value></data>
<data name="Results.Empty"><value>Результатов в выбранном диапазоне ещё нет. Запустите загрузку или подождите завершения матчей.</value></data>
<data name="Results.Footer.Items"><value>результатов</value></data>
<data name="Results.Loader.Kicker"><value>Загрузка</value></data>
<data name="Results.Loader.Title"><value>Загрузить результаты</value></data>
<data name="Results.Loader.Lede"><value>Опросим страницу каждого события, заберём финальный счёт и сторону-победителя. Выберите диапазон или конкретные события.</value></data>
<data name="Results.Loader.Mode"><value>Режим</value></data>
<data name="Results.Loader.Mode.AllInRange"><value>Все в диапазоне</value></data>
<data name="Results.Loader.Mode.Selected"><value>Выбранные события</value></data>
<data name="Results.Loader.Selected.Empty"><value>Все события в этом диапазоне уже имеют результат.</value></data>
<data name="Results.Loader.Selected.CountFormat"><value>{0} выбрано</value></data>
<data name="Results.Loader.Action.Load"><value>Загрузить</value></data>
<data name="Results.Loader.Action.Cancel"><value>Отменить</value></data>
<data name="Results.Loader.Action.Back"><value>Назад</value></data>
<data name="Results.Loader.Progress.Format"><value>{0} / {1}</value></data>
<data name="Results.Loader.Progress.Loaded"><value>Загружено</value></data>
<data name="Results.Loader.Progress.AlreadyLoaded"><value>Уже было</value></data>
<data name="Results.Loader.Progress.NotYetComplete"><value>Не завершено</value></data>
<data name="Results.Loader.Progress.Failed"><value>Ошибка</value></data>
<data name="Results.Loader.Summary.Format"><value>Загружено {0}, пропущено {1}, всего обработано {2}.</value></data>
<data name="Results.Loader.Empty.NoCandidates"><value>Нет событий для загрузки в этом диапазоне.</value></data>
</root>
@@ -0,0 +1,69 @@
using Marathon.Application.Storage;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
/// Read-only browsing facade over the EventResult / Event repositories.
/// The Results pages depend on this — never directly on
/// <c>IResultRepository</c> — so view-model shaping stays in one place.
/// </summary>
public interface IResultsBrowsingService
{
/// <summary>
/// Lists already-loaded results matching <paramref name="filter"/>.
/// Joins the underlying <c>Event</c> rows so the UI can render team
/// names, sport, and league without a second round-trip.
/// </summary>
Task<IReadOnlyList<EventResultListItem>> ListResultsAsync(
ResultsFilter filter,
CancellationToken ct);
/// <summary>
/// Lists events scheduled in <paramref name="range"/> that DO NOT yet
/// have a stored result — used to populate the "selected events"
/// multi-select on the Results loader page.
/// </summary>
Task<IReadOnlyList<EventResultCandidate>> ListLoadCandidatesAsync(
DateRange range,
CancellationToken ct);
}
/// <summary>
/// View-model row shown on the Results list page.
/// </summary>
public sealed record EventResultListItem(
Marathon.Domain.ValueObjects.EventId Id,
SportCode Sport,
string CountryCode,
string LeagueId,
string Side1Name,
string Side2Name,
DateTimeOffset ScheduledAt,
int Side1Score,
int Side2Score,
Side WinnerSide,
DateTimeOffset CompletedAt);
/// <summary>
/// Slim view-model for the loader's "selected events" picker.
/// </summary>
public sealed record EventResultCandidate(
Marathon.Domain.ValueObjects.EventId Id,
SportCode Sport,
string CountryCode,
string LeagueId,
string Side1Name,
string Side2Name,
DateTimeOffset ScheduledAt);
/// <summary>
/// Filter for the Results list page. <see cref="WinnerSide"/> = null means
/// "any winner" (no filter); otherwise narrows to that outcome.
/// </summary>
public sealed record ResultsFilter(
DateRangeFilter Dates,
IReadOnlyCollection<int>? SportCodes = null,
Side? WinnerSide = null,
string? SearchTerm = null);
@@ -0,0 +1,98 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
namespace Marathon.UI.Services;
/// <summary>
/// Default implementation of <see cref="IResultsBrowsingService"/> backed by
/// the event + result repositories. Reads the full slice into memory and
/// shapes view-models — the page-level filter slices are small enough that
/// in-memory composition is the simpler choice.
/// </summary>
public sealed class ResultsBrowsingService : IResultsBrowsingService
{
private readonly IEventRepository _events;
private readonly IResultRepository _results;
public ResultsBrowsingService(IEventRepository events, IResultRepository results)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
}
public async Task<IReadOnlyList<EventResultListItem>> ListResultsAsync(
ResultsFilter filter,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(filter);
var range = new DateRange(filter.Dates.From, filter.Dates.To);
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
var allResults = await _results.ListAsync(ct).ConfigureAwait(false);
var resultsByEventId = allResults.ToDictionary(r => r.EventId.Value, r => r);
var rows = new List<EventResultListItem>();
foreach (var ev in events)
{
if (!resultsByEventId.TryGetValue(ev.Id.Value, out var result))
continue;
if (filter.SportCodes is { Count: > 0 } sports
&& !sports.Contains(ev.Sport.Value))
continue;
if (filter.WinnerSide is { } winner && result.WinnerSide != winner)
continue;
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
{
var term = filter.SearchTerm.Trim();
var matches =
ev.LeagueId.Contains(term, StringComparison.OrdinalIgnoreCase) ||
ev.Side1Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
ev.Side2Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
ev.Category.Contains(term, StringComparison.OrdinalIgnoreCase);
if (!matches) continue;
}
rows.Add(new EventResultListItem(
Id: ev.Id,
Sport: ev.Sport,
CountryCode: ev.CountryCode,
LeagueId: ev.LeagueId,
Side1Name: ev.Side1Name,
Side2Name: ev.Side2Name,
ScheduledAt: ev.ScheduledAt,
Side1Score: result.Side1Score,
Side2Score: result.Side2Score,
WinnerSide: result.WinnerSide,
CompletedAt: result.CompletedAt));
}
// Most-recent first.
return rows.OrderByDescending(r => r.CompletedAt).ToList();
}
public async Task<IReadOnlyList<EventResultCandidate>> ListLoadCandidatesAsync(
DateRange range,
CancellationToken ct)
{
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
var allResults = await _results.ListAsync(ct).ConfigureAwait(false);
var withResult = allResults.Select(r => r.EventId.Value).ToHashSet(StringComparer.Ordinal);
return events
.Where(e => !withResult.Contains(e.Id.Value))
.OrderBy(e => e.ScheduledAt)
.Select(e => new EventResultCandidate(
Id: e.Id,
Sport: e.Sport,
CountryCode: e.CountryCode,
LeagueId: e.LeagueId,
Side1Name: e.Side1Name,
Side2Name: e.Side2Name,
ScheduledAt: e.ScheduledAt))
.ToList();
}
}
@@ -33,7 +33,13 @@ public static class UiServicesExtensions
services.AddMudServices();
services.AddLocalization(options => options.ResourcesPath = "Resources");
// No ResourcesPath: the SharedResource type already lives in the
// Marathon.UI.Resources namespace, so its compiled .resources name
// is "Marathon.UI.Resources.SharedResource.{culture}.resources" which
// matches the type's FullName directly. Setting ResourcesPath="Resources"
// here would cause the resolver to look for "...Resources.Resources..."
// and silently fall back to displaying the keys.
services.AddLocalization();
// Strongly typed options bound to appsettings.json sections.
services.Configure<LocalizationOptions>(configuration.GetSection(LocalizationOptions.SectionName));
@@ -51,6 +57,7 @@ public static class UiServicesExtensions
// Browsing facades — Scoped so they capture the per-circuit repository scope.
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -0,0 +1,96 @@
using AngleSharp.Dom;
using Bunit;
using Marathon.Domain.Enums;
using Marathon.UI.Pages.Results;
using Marathon.UI.Tests.Support;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
namespace Marathon.UI.Tests.Pages.Results;
public sealed class ResultsListTests : MarathonTestContext
{
[Fact]
public void Renders_seeded_results()
{
Browsing.SportCodes.AddRange(new[] { 11, 6 });
Results.ResultItems.AddRange(new[]
{
TestData.ResultListItem(id: "EV-1", side1: "Arsenal", side2: "Chelsea", side1Score: 2, side2Score: 1, winner: Side.Side1),
TestData.ResultListItem(id: "EV-2", side1: "Lakers", side2: "Bulls", sport: 6, side1Score: 105, side2Score: 110, winner: Side.Side2),
});
var cut = RenderComponent<ResultsList>();
cut.WaitForAssertion(() =>
{
var rows = cut.FindAll("[data-test=results-row]");
rows.Count.Should().Be(2);
});
cut.Markup.Should().Contain("Arsenal");
cut.Markup.Should().Contain("Lakers");
var scoreCells = cut.FindAll("[data-test=results-score]").Select(e => e.TextContent.Trim()).ToList();
scoreCells.Should().Contain("2:1");
scoreCells.Should().Contain("105:110");
}
[Fact]
public void Renders_empty_state_when_no_results()
{
var cut = RenderComponent<ResultsList>();
cut.WaitForAssertion(() =>
{
cut.Markup.Should().Contain("Results.Empty");
});
}
[Fact]
public void Winner_filter_narrows_to_selected_side()
{
Results.ResultItems.AddRange(new[]
{
TestData.ResultListItem(id: "EV-1", side1: "Arsenal", side2: "Chelsea", winner: Side.Side1),
TestData.ResultListItem(id: "EV-2", side1: "Real", side2: "Barca", winner: Side.Side2),
TestData.ResultListItem(id: "EV-3", side1: "City", side2: "Spurs", winner: Side.Draw),
});
var cut = RenderComponent<ResultsList>();
cut.WaitForAssertion(() => cut.FindAll("[data-test=results-row]").Count.Should().Be(3));
// Filter by Side2 winner — expect only ev-2
cut.Find("[data-test=results-winner-filter]").Change("Side2");
cut.WaitForAssertion(() =>
{
Results.LastResultsFilter!.WinnerSide.Should().Be(Side.Side2);
cut.FindAll("[data-test=results-row]").Count.Should().Be(1);
cut.Markup.Should().Contain("Real");
});
}
[Fact]
public void Open_loader_button_navigates_to_loader_route()
{
var nav = Services.GetRequiredService<NavigationManager>();
var cut = RenderComponent<ResultsList>();
cut.WaitForAssertion(() => cut.Find("[data-test=results-open-loader]"));
cut.Find("[data-test=results-open-loader]").Click();
nav.Uri.Should().EndWith("/results/load");
}
[Fact]
public void Clicking_a_row_navigates_to_event_detail()
{
Results.ResultItems.Add(TestData.ResultListItem(id: "EV-42"));
var nav = Services.GetRequiredService<NavigationManager>();
var cut = RenderComponent<ResultsList>();
cut.WaitForAssertion(() => cut.Find("[data-test=results-row]"));
cut.Find("[data-test=results-row]").Click();
nav.Uri.Should().Contain("/events/EV-42");
}
}
@@ -0,0 +1,208 @@
using Bunit;
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.UI.Pages.Results;
using Marathon.UI.Tests.Support;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.UI.Tests.Pages.Results;
public sealed class ResultsLoaderTests : MarathonTestContext
{
private readonly IOddsScraper _scraper = Substitute.For<IOddsScraper>();
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
public ResultsLoaderTests()
{
// The page resolves PullResultsUseCase via IServiceProvider.CreateAsyncScope(),
// so the use case must be reachable from the bUnit container. We register a
// factory that builds a real use-case wired to the substituted dependencies.
Services.AddScoped(_ => _scraper);
Services.AddScoped(_ => _eventRepo);
Services.AddScoped(_ => _resultRepo);
Services.AddScoped(sp => new PullResultsUseCase(
sp.GetRequiredService<IOddsScraper>(),
sp.GetRequiredService<IEventRepository>(),
sp.GetRequiredService<IResultRepository>(),
NullLogger<PullResultsUseCase>.Instance));
}
[Fact]
public void Renders_back_button_and_form()
{
var cut = RenderComponent<ResultsLoader>();
cut.WaitForAssertion(() =>
{
cut.Find("[data-test=results-loader-back]");
cut.Find("[data-test=results-loader-mode]");
cut.Find("[data-test=results-loader-start]");
});
}
[Fact]
public void Selected_mode_shows_candidates()
{
Results.Candidates.AddRange(new[]
{
TestData.ResultCandidate(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
TestData.ResultCandidate(id: "EV-2", side1: "Lakers", side2: "Bulls", sport: 6),
});
var cut = RenderComponent<ResultsLoader>();
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-mode]"));
cut.Find("[data-test=results-loader-mode]").Change("Selected");
cut.WaitForAssertion(() =>
{
var rows = cut.FindAll("[data-test=results-loader-candidate]");
rows.Count.Should().Be(2);
});
}
[Fact]
public void Empty_candidates_shows_empty_state_in_selected_mode()
{
var cut = RenderComponent<ResultsLoader>();
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-mode]"));
cut.Find("[data-test=results-loader-mode]").Change("Selected");
cut.WaitForAssertion(() =>
{
cut.Find("[data-test=results-loader-no-candidates]");
});
}
[Fact]
public void All_in_range_load_invokes_scraper_for_every_candidate_and_reports_progress()
{
// Two candidates returned by the browsing service — but the use case
// calls _eventRepo.ListByDateRangeAsync (NULL selection path), not the
// browsing service. So we wire the EventRepository directly.
var ev1 = TestEvent("EV-1", "Arsenal", "Chelsea");
var ev2 = TestEvent("EV-2", "Lakers", "Bulls");
_eventRepo.ListByDateRangeAsync(Arg.Any<Marathon.Application.Storage.DateRange>(), Arg.Any<CancellationToken>())
.Returns(new List<Event> { ev1, ev2 }.AsReadOnly());
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
var result1 = new EventResult(ev1.Id, 2, 1, Side.Side1, DateTimeOffset.UtcNow);
var result2 = new EventResult(ev2.Id, 99, 105, Side.Side2, DateTimeOffset.UtcNow);
_scraper.ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev1.Id), Arg.Any<CancellationToken>())
.Returns(result1);
_scraper.ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev2.Id), Arg.Any<CancellationToken>())
.Returns(result2);
// The page also queries the browsing service for candidates so the Load
// button is enabled.
Results.Candidates.AddRange(new[]
{
TestData.ResultCandidate(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
TestData.ResultCandidate(id: "EV-2", side1: "Lakers", side2: "Bulls"),
});
var cut = RenderComponent<ResultsLoader>();
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-start]"));
cut.Find("[data-test=results-loader-start]").Click();
cut.WaitForAssertion(() =>
{
// Both events scraped → both rows appear in the progress log; the
// summary element is rendered (its text uses a localizer key that
// the test stub doesn't substitute, so we don't assert on its text).
cut.Find("[data-test=results-loader-summary]");
cut.FindAll("[data-test=results-loader-log-row]").Count.Should().Be(2);
});
// Both events were inspected via the scraper
_scraper.Received(1).ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev1.Id), Arg.Any<CancellationToken>());
_scraper.Received(1).ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev2.Id), Arg.Any<CancellationToken>());
}
[Fact]
public void Selected_mode_load_invokes_scraper_only_for_picked_events()
{
var ev1 = TestEvent("EV-1", "Arsenal", "Chelsea");
var ev2 = TestEvent("EV-2", "Lakers", "Bulls");
// Use case selection-mode resolves each picked event via _eventRepo.GetAsync.
_eventRepo.GetAsync(ev1.Id, Arg.Any<CancellationToken>()).Returns(ev1);
_eventRepo.GetAsync(ev2.Id, Arg.Any<CancellationToken>()).Returns(ev2);
_resultRepo.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
_scraper.ScrapeEventResultAsync(Arg.Any<Event>(), Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
Results.Candidates.AddRange(new[]
{
TestData.ResultCandidate(id: "EV-1", side1: "Arsenal", side2: "Chelsea"),
TestData.ResultCandidate(id: "EV-2", side1: "Lakers", side2: "Bulls"),
});
var cut = RenderComponent<ResultsLoader>();
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-mode]"));
cut.Find("[data-test=results-loader-mode]").Change("Selected");
cut.WaitForAssertion(() => cut.FindAll("[data-test=results-loader-candidate] input[type=checkbox]").Count.Should().Be(2));
// Pick only EV-1
var checkboxes = cut.FindAll("[data-test=results-loader-candidate] input[type=checkbox]");
checkboxes[0].Change(true);
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-start]"));
cut.Find("[data-test=results-loader-start]").Click();
cut.WaitForAssertion(() =>
{
cut.Find("[data-test=results-loader-summary]");
});
// Only EV-1 was inspected via use-case
_eventRepo.Received(1).GetAsync(ev1.Id, Arg.Any<CancellationToken>());
_eventRepo.DidNotReceive().GetAsync(ev2.Id, Arg.Any<CancellationToken>());
_scraper.Received(1).ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev1.Id), Arg.Any<CancellationToken>());
_scraper.DidNotReceive().ScrapeEventResultAsync(Arg.Is<Event>(e => e.Id == ev2.Id), Arg.Any<CancellationToken>());
}
[Fact]
public void Back_button_navigates_to_results_list()
{
var nav = Services.GetRequiredService<NavigationManager>();
var cut = RenderComponent<ResultsLoader>();
cut.WaitForAssertion(() => cut.Find("[data-test=results-loader-back]"));
cut.Find("[data-test=results-loader-back]").Click();
nav.Uri.Should().EndWith("/results");
}
private static Event TestEvent(string id, string side1, string side2)
{
var moscowOffset = TimeSpan.FromHours(3);
var todayMoscow = DateTimeOffset.UtcNow.ToOffset(moscowOffset);
var midnightMoscow = new DateTimeOffset(
todayMoscow.Year, todayMoscow.Month, todayMoscow.Day,
18, 0, 0, moscowOffset);
return new Event(
new EventId(id),
new SportCode(11),
"ENG",
"Premier League",
"Group A",
midnightMoscow,
side1,
side2);
}
}
@@ -0,0 +1,49 @@
using Marathon.Application.Storage;
using Marathon.UI.Services;
namespace Marathon.UI.Tests.Support;
/// <summary>
/// In-memory <see cref="IResultsBrowsingService"/> for bUnit tests.
/// Seed via <see cref="ResultItems"/> / <see cref="Candidates"/>.
/// </summary>
public sealed class FakeResultsBrowsingService : IResultsBrowsingService
{
public List<EventResultListItem> ResultItems { get; } = new();
public List<EventResultCandidate> Candidates { get; } = new();
public ResultsFilter? LastResultsFilter { get; private set; }
public DateRange? LastCandidatesRange { get; private set; }
public Task<IReadOnlyList<EventResultListItem>> ListResultsAsync(
ResultsFilter filter,
CancellationToken ct)
{
LastResultsFilter = filter;
IEnumerable<EventResultListItem> q = ResultItems;
if (filter.SportCodes is { Count: > 0 } sports)
q = q.Where(r => sports.Contains(r.Sport.Value));
if (filter.WinnerSide is { } winner)
q = q.Where(r => r.WinnerSide == winner);
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
{
var t = filter.SearchTerm.Trim();
q = q.Where(r =>
r.LeagueId.Contains(t, StringComparison.OrdinalIgnoreCase) ||
r.Side1Name.Contains(t, StringComparison.OrdinalIgnoreCase) ||
r.Side2Name.Contains(t, StringComparison.OrdinalIgnoreCase));
}
return Task.FromResult<IReadOnlyList<EventResultListItem>>(q.ToList());
}
public Task<IReadOnlyList<EventResultCandidate>> ListLoadCandidatesAsync(
DateRange range,
CancellationToken ct)
{
LastCandidatesRange = range;
return Task.FromResult<IReadOnlyList<EventResultCandidate>>(Candidates.ToList());
}
}
@@ -23,6 +23,7 @@ public abstract class MarathonTestContext : TestContext
protected FakeEventBrowsingService Browsing { get; } = new();
protected AnomalyBrowsingState AnomalyState { get; } = new();
protected FakeAnomalyBrowsingService AnomalyBrowsing { get; } = new();
protected FakeResultsBrowsingService Results { get; } = new();
protected MarathonTestContext()
{
@@ -34,6 +35,7 @@ public abstract class MarathonTestContext : TestContext
Services.AddSingleton<IEventBrowsingService>(Browsing);
Services.AddSingleton(AnomalyState);
Services.AddSingleton<IAnomalyBrowsingService>(AnomalyBrowsing);
Services.AddSingleton<IResultsBrowsingService>(Results);
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
@@ -44,6 +44,48 @@ internal static class TestData
return midnight.AddHours(hour);
}
public static EventResultListItem ResultListItem(
string id = "100001",
int sport = 11,
string country = "ENG",
string league = "Premier League",
string side1 = "Arsenal",
string side2 = "Chelsea",
int side1Score = 2,
int side2Score = 1,
Side winner = Side.Side1,
DateTimeOffset? scheduled = null,
DateTimeOffset? completed = null)
=> new(
new EventId(id),
new SportCode(sport),
country,
league,
side1,
side2,
scheduled ?? MoscowToday(20),
side1Score,
side2Score,
winner,
completed ?? MoscowToday(22));
public static EventResultCandidate ResultCandidate(
string id = "100001",
int sport = 11,
string country = "ENG",
string league = "Premier League",
string side1 = "Arsenal",
string side2 = "Chelsea",
DateTimeOffset? scheduled = null)
=> new(
new EventId(id),
new SportCode(sport),
country,
league,
side1,
side2,
scheduled ?? MoscowToday(20));
public static EventDetail Detail(
string id = "100001",
params OddsTimelinePoint[] timeline)