f294255f10
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at 6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing, results selection); guarded by a Received(1).GetManyAsync test. - Add EventRepository.QueryAsync to push date+sport filtering to SQL (was load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order. - Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync (feed date filter); add Event/Snapshot count methods for the dashboard. - Add composite indexes IX_Snapshots_EventCode_CapturedAt and _EventCode_Source_CapturedAt via a new migration + model snapshot. - Introduce SqliteDateText as the single source of the O-format date encoding shared by Mapping (read/write) and the repositories' range predicates. - Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join. Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
224 lines
9.1 KiB
C#
224 lines
9.1 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));
|
|
|
|
// PullResultsUseCase batches selection-mode candidate resolution via
|
|
// GetManyAsync; route it through whatever GetAsync the test configures.
|
|
_eventRepo.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
|
.Returns(ci =>
|
|
{
|
|
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
|
|
var dict = new Dictionary<EventId, Event>();
|
|
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<EventId, Event>)dict;
|
|
});
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|