From 9f090cec1f5c67bf62432e07d12c7fbf0718bba9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 15:10:49 +0300 Subject: [PATCH] feat(phase-8-frontend): results loader UI + browsing list + 41 localization keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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, 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. --- src/Marathon.UI/Pages/Results.razor | 5 - .../Pages/Results/ResultsList.razor | 330 ++++++++++++++ .../Pages/Results/ResultsLoader.razor | 419 ++++++++++++++++++ .../Resources/SharedResource.en.resx | 43 ++ .../Resources/SharedResource.ru.resx | 43 ++ .../Services/IResultsBrowsingService.cs | 69 +++ .../Services/ResultsBrowsingService.cs | 98 ++++ .../Services/UiServicesExtensions.cs | 9 +- .../Pages/Results/ResultsListTests.cs | 96 ++++ .../Pages/Results/ResultsLoaderTests.cs | 208 +++++++++ .../Support/FakeResultsBrowsingService.cs | 49 ++ .../Support/MarathonTestContext.cs | 2 + tests/Marathon.UI.Tests/Support/TestData.cs | 42 ++ 13 files changed, 1407 insertions(+), 6 deletions(-) delete mode 100644 src/Marathon.UI/Pages/Results.razor create mode 100644 src/Marathon.UI/Pages/Results/ResultsList.razor create mode 100644 src/Marathon.UI/Pages/Results/ResultsLoader.razor create mode 100644 src/Marathon.UI/Services/IResultsBrowsingService.cs create mode 100644 src/Marathon.UI/Services/ResultsBrowsingService.cs create mode 100644 tests/Marathon.UI.Tests/Pages/Results/ResultsListTests.cs create mode 100644 tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs create mode 100644 tests/Marathon.UI.Tests/Support/FakeResultsBrowsingService.cs diff --git a/src/Marathon.UI/Pages/Results.razor b/src/Marathon.UI/Pages/Results.razor deleted file mode 100644 index 5ceccfa..0000000 --- a/src/Marathon.UI/Pages/Results.razor +++ /dev/null @@ -1,5 +0,0 @@ -@page "/results" -@inject IStringLocalizer L - -@L["App.Title"] · @L["Nav.Results"] - diff --git a/src/Marathon.UI/Pages/Results/ResultsList.razor b/src/Marathon.UI/Pages/Results/ResultsList.razor new file mode 100644 index 0000000..faa63c0 --- /dev/null +++ b/src/Marathon.UI/Pages/Results/ResultsList.razor @@ -0,0 +1,330 @@ +@page "/results" +@using Marathon.Domain.Enums +@using Marathon.UI.Components +@using Marathon.UI.Services +@inject IStringLocalizer L +@inject IResultsBrowsingService Browsing +@inject IEventBrowsingService Events +@inject NavigationManager Nav +@implements IDisposable + +@L["App.Title"] · @L["Nav.Results"] + +
+
+ @L["Nav.Section.Analysis"] +

@L["Results.Title"]

+

@L["Results.Lede"]

+
+ + @L["Results.Action.LoadNew"] + +
+
+ + + +
+ @if (_loading) + { +
+ + @L["Common.Loading"] +
+ } + else if (_rows.Count == 0) + { +
+ @L["Common.Empty"] +

@L["Results.Empty"]

+
+ } + else + { + + + + + + + + + + + + + + + @foreach (var row in _rows) + { + + + + + + + + + + + } + +
@L["Results.Column.Time"]@L["Results.Column.Country"]@L["Results.Column.League"]@L["Results.Column.Match"]@L["Results.Column.Score"]@L["Results.Column.Winner"]@L["Results.Column.CompletedAt"]
+ + @row.ScheduledAt.ToString("dd MMM HH:mm")@row.CountryCode@row.LeagueId@row.Side1Name vs @row.Side2Name + @row.Side1Score:@row.Side2Score + + + @WinnerLabel(row.WinnerSide) + + @row.CompletedAt.ToLocalTime().ToString("dd MMM HH:mm")
+ + } +
+
+ + + +@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 _sportCodes = new(); + + private IReadOnlyList _availableSports = Array.Empty(); + private List _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(); + } + + 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(); + } +} diff --git a/src/Marathon.UI/Pages/Results/ResultsLoader.razor b/src/Marathon.UI/Pages/Results/ResultsLoader.razor new file mode 100644 index 0000000..c707d13 --- /dev/null +++ b/src/Marathon.UI/Pages/Results/ResultsLoader.razor @@ -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 L +@inject IResultsBrowsingService Browsing +@inject NavigationManager Nav +@inject IServiceProvider Sp +@implements IDisposable + +@L["App.Title"] · @L["Results.Loader.Title"] + +
+
+ @L["Results.Loader.Kicker"] +

@L["Results.Loader.Title"]

+

@L["Results.Loader.Lede"]

+
+ + @L["Results.Loader.Action.Back"] + +
+
+ + + + @if (_running || _completed) + { +
+
+ + @string.Format(L["Results.Loader.Progress.Format"], _processed, _total) + + @if (_completed) + { + + @string.Format(L["Results.Loader.Summary.Format"], _summaryLoaded, _summarySkipped, _summaryProcessed) + + } +
+ + + @if (_progressLog.Count > 0) + { + + + + + + + + + + + @foreach (var entry in _progressLog) + { + + + + + + + } + +
@L["Results.Column.Match"]@L["Results.Column.Score"]@L["Results.Column.Winner"]Outcome
@entry.MatchLabel + @if (entry.Loaded is { } r) + { + @($"{r.Side1Score}:{r.Side2Score}") + } + else + { + @("—") + } + + @if (entry.Loaded is { WinnerSide: var w }) + { + @WinnerLabel(w) + } + + + @OutcomeLabel(entry.Outcome) + +
+ } +
+ } +
+ + + +@code { + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + + private DateTimeOffset _from; + private DateTimeOffset _to; + private LoaderMode _mode = LoaderMode.AllInRange; + + private List _candidates = new(); + private HashSet _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 _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(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? 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(); + + var progress = new Progress(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(); + } +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 050aff5..aaa0390 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -258,4 +258,47 @@ Football Tennis Hockey + + Match results + Final scores of loaded events. We walk each event page, wait for matchIsComplete=true, and record the winning side. + Load results + Back to list + From + To + Search + Team, league, category… + Sport + Winner + Any + Side 1 + Side 2 + Draw + Time + Country + League + Match + Score + Winner + Completed + No results loaded for this range yet. Run a load or wait for matches to complete. + results + + Loader + Load results + We poll each event page, capture the final score, and record the winning side. Pick a date range or specific events. + Mode + All in range + Selected events + Every event in this range already has a result. + {0} selected + Load + Cancel + Back + {0} / {1} + Loaded + Already loaded + Not yet complete + Failed + Loaded {0}, skipped {1}, processed {2} total. + No events to load in this range. diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 1929416..0c886ce 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -271,4 +271,47 @@ Футбол Теннис Хоккей + + Результаты матчей + Финальные счета загруженных событий. Обходим страницу события, ждём matchIsComplete=true, фиксируем сторону-победителя. + Загрузить результаты + К списку + С + По + Поиск + Команда, лига, категория… + Спорт + Победитель + Любой + Команда 1 + Команда 2 + Ничья + Время + Страна + Лига + Матч + Счёт + Победитель + Завершено + Результатов в выбранном диапазоне ещё нет. Запустите загрузку или подождите завершения матчей. + результатов + + Загрузка + Загрузить результаты + Опросим страницу каждого события, заберём финальный счёт и сторону-победителя. Выберите диапазон или конкретные события. + Режим + Все в диапазоне + Выбранные события + Все события в этом диапазоне уже имеют результат. + {0} выбрано + Загрузить + Отменить + Назад + {0} / {1} + Загружено + Уже было + Не завершено + Ошибка + Загружено {0}, пропущено {1}, всего обработано {2}. + Нет событий для загрузки в этом диапазоне. diff --git a/src/Marathon.UI/Services/IResultsBrowsingService.cs b/src/Marathon.UI/Services/IResultsBrowsingService.cs new file mode 100644 index 0000000..34f6521 --- /dev/null +++ b/src/Marathon.UI/Services/IResultsBrowsingService.cs @@ -0,0 +1,69 @@ +using Marathon.Application.Storage; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.UI.Services; + +/// +/// Read-only browsing facade over the EventResult / Event repositories. +/// The Results pages depend on this — never directly on +/// IResultRepository — so view-model shaping stays in one place. +/// +public interface IResultsBrowsingService +{ + /// + /// Lists already-loaded results matching . + /// Joins the underlying Event rows so the UI can render team + /// names, sport, and league without a second round-trip. + /// + Task> ListResultsAsync( + ResultsFilter filter, + CancellationToken ct); + + /// + /// Lists events scheduled in that DO NOT yet + /// have a stored result — used to populate the "selected events" + /// multi-select on the Results loader page. + /// + Task> ListLoadCandidatesAsync( + DateRange range, + CancellationToken ct); +} + +/// +/// View-model row shown on the Results list page. +/// +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); + +/// +/// Slim view-model for the loader's "selected events" picker. +/// +public sealed record EventResultCandidate( + Marathon.Domain.ValueObjects.EventId Id, + SportCode Sport, + string CountryCode, + string LeagueId, + string Side1Name, + string Side2Name, + DateTimeOffset ScheduledAt); + +/// +/// Filter for the Results list page. = null means +/// "any winner" (no filter); otherwise narrows to that outcome. +/// +public sealed record ResultsFilter( + DateRangeFilter Dates, + IReadOnlyCollection? SportCodes = null, + Side? WinnerSide = null, + string? SearchTerm = null); diff --git a/src/Marathon.UI/Services/ResultsBrowsingService.cs b/src/Marathon.UI/Services/ResultsBrowsingService.cs new file mode 100644 index 0000000..001fcb7 --- /dev/null +++ b/src/Marathon.UI/Services/ResultsBrowsingService.cs @@ -0,0 +1,98 @@ +using Marathon.Application.Abstractions; +using Marathon.Application.Storage; +using Marathon.Domain.Entities; + +namespace Marathon.UI.Services; + +/// +/// Default implementation of 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. +/// +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> 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(); + 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> 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(); + } +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index a12864f..160d69f 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -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(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(); services.AddScoped(); + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/tests/Marathon.UI.Tests/Pages/Results/ResultsListTests.cs b/tests/Marathon.UI.Tests/Pages/Results/ResultsListTests.cs new file mode 100644 index 0000000..74e02a4 --- /dev/null +++ b/tests/Marathon.UI.Tests/Pages/Results/ResultsListTests.cs @@ -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(); + 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(); + 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(); + 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(); + + var cut = RenderComponent(); + 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(); + + var cut = RenderComponent(); + cut.WaitForAssertion(() => cut.Find("[data-test=results-row]")); + + cut.Find("[data-test=results-row]").Click(); + + nav.Uri.Should().Contain("/events/EV-42"); + } +} diff --git a/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs b/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs new file mode 100644 index 0000000..cf49e47 --- /dev/null +++ b/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs @@ -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(); + private readonly IEventRepository _eventRepo = Substitute.For(); + private readonly IResultRepository _resultRepo = Substitute.For(); + + 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(), + sp.GetRequiredService(), + sp.GetRequiredService(), + NullLogger.Instance)); + } + + [Fact] + public void Renders_back_button_and_form() + { + var cut = RenderComponent(); + 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(); + 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(); + 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(), Arg.Any()) + .Returns(new List { ev1, ev2 }.AsReadOnly()); + _resultRepo.GetAsync(Arg.Any(), Arg.Any()) + .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(e => e.Id == ev1.Id), Arg.Any()) + .Returns(result1); + _scraper.ScrapeEventResultAsync(Arg.Is(e => e.Id == ev2.Id), Arg.Any()) + .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(); + 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(e => e.Id == ev1.Id), Arg.Any()); + _scraper.Received(1).ScrapeEventResultAsync(Arg.Is(e => e.Id == ev2.Id), Arg.Any()); + } + + [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()).Returns(ev1); + _eventRepo.GetAsync(ev2.Id, Arg.Any()).Returns(ev2); + _resultRepo.GetAsync(Arg.Any(), Arg.Any()) + .Returns((EventResult?)null); + + _scraper.ScrapeEventResultAsync(Arg.Any(), Arg.Any()) + .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(); + 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()); + _eventRepo.DidNotReceive().GetAsync(ev2.Id, Arg.Any()); + _scraper.Received(1).ScrapeEventResultAsync(Arg.Is(e => e.Id == ev1.Id), Arg.Any()); + _scraper.DidNotReceive().ScrapeEventResultAsync(Arg.Is(e => e.Id == ev2.Id), Arg.Any()); + } + + [Fact] + public void Back_button_navigates_to_results_list() + { + var nav = Services.GetRequiredService(); + + var cut = RenderComponent(); + 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); + } +} diff --git a/tests/Marathon.UI.Tests/Support/FakeResultsBrowsingService.cs b/tests/Marathon.UI.Tests/Support/FakeResultsBrowsingService.cs new file mode 100644 index 0000000..66f8254 --- /dev/null +++ b/tests/Marathon.UI.Tests/Support/FakeResultsBrowsingService.cs @@ -0,0 +1,49 @@ +using Marathon.Application.Storage; +using Marathon.UI.Services; + +namespace Marathon.UI.Tests.Support; + +/// +/// In-memory for bUnit tests. +/// Seed via / . +/// +public sealed class FakeResultsBrowsingService : IResultsBrowsingService +{ + public List ResultItems { get; } = new(); + public List Candidates { get; } = new(); + public ResultsFilter? LastResultsFilter { get; private set; } + public DateRange? LastCandidatesRange { get; private set; } + + public Task> ListResultsAsync( + ResultsFilter filter, + CancellationToken ct) + { + LastResultsFilter = filter; + IEnumerable 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>(q.ToList()); + } + + public Task> ListLoadCandidatesAsync( + DateRange range, + CancellationToken ct) + { + LastCandidatesRange = range; + return Task.FromResult>(Candidates.ToList()); + } +} diff --git a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs index af2974c..1a3a4a5 100644 --- a/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs +++ b/tests/Marathon.UI.Tests/Support/MarathonTestContext.cs @@ -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(Browsing); Services.AddSingleton(AnomalyState); Services.AddSingleton(AnomalyBrowsing); + Services.AddSingleton(Results); Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>)); Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); diff --git a/tests/Marathon.UI.Tests/Support/TestData.cs b/tests/Marathon.UI.Tests/Support/TestData.cs index 86b9d7c..09e696d 100644 --- a/tests/Marathon.UI.Tests/Support/TestData.cs +++ b/tests/Marathon.UI.Tests/Support/TestData.cs @@ -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)