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