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)); // PullResultsUseCase batches selection-mode candidate resolution via // GetManyAsync; route it through whatever GetAsync the test configures. _eventRepo.GetManyAsync(Arg.Any>(), Arg.Any()) .Returns(ci => { var ids = ci.Arg>(); var dict = new Dictionary(); foreach (var id in ids.Distinct()) { var ev = _eventRepo.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult(); if (ev is not null) dict[id] = ev; } return (IReadOnlyDictionary)dict; }); } [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); } }