Files
maraphon-app/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs
T
alexei.dolgolyov 9f090cec1f 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.
2026-05-09 15:10:49 +03:00

209 lines
8.4 KiB
C#

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